feat: ja4-platform monorepo — 5 services unified, tests & RPM builds standardized
Services: - ja4sentinel: TLS/JA4 fingerprint capture daemon (Go, libpcap) - logcorrelator: JA4 log correlation engine (Go, ClickHouse) - mod_reqin_log: Apache module (C, JSON request logging) - bot_detector: ML bot detection pipeline (Python) - dashboard: FastAPI/Streamlit analytics UI (Python) Shared libraries: - shared/go/ja4common: logger, config, shutdown, ipfilter (Go module) - shared/python/ja4_common: ClickHouseClient, ClickHouseSettings (Python package) - shared/clickhouse/: canonical SQL migrations (10 files) Build & packaging: - Unified 3-stage Dockerfile.package for Go RPMs (el8/el9/el10) - go.work workspace linking sentinel, correlator, ja4common - Makefile with test-all, build-all, rpm-* targets Fixes applied: - go.work: 1.21 → 1.24.6 (required by sentinel) - correlator Dockerfiles: golang:1.21 → golang:1.24 - replace directives in go.mod for ja4common local path - pyproject.toml: setuptools.backends → setuptools.build_meta - Removed static libpcap linking (unavailable on Rocky 9) - Fixed data races in output/writers_test.go (sync.Mutex + atomic.Int32) - Rewrote corrupted test files (logger_test.go × 2) Test coverage: - correlator: 67.1% total (unixsocket 80.5%, config 91.7%, app 83.3%, multi 87.7%, stdout 100%) - sentinel: all 10 packages pass (api, capture, config, fingerprint, ipfilter, logging, output, tlsparse) Documentation: - README.md + docs/ (architecture, development, 5 services, shared libs, DB schema & migrations) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
8
services/dashboard/.env.example
Normal file
8
services/dashboard/.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# dashboard configuration — DO NOT COMMIT real values
|
||||
CLICKHOUSE_HOST=clickhouse
|
||||
CLICKHOUSE_PORT=8123
|
||||
CLICKHOUSE_DB=mabase_prod
|
||||
CLICKHOUSE_USER=analyst
|
||||
CLICKHOUSE_PASSWORD=
|
||||
API_HOST=0.0.0.0
|
||||
CORS_ORIGINS=["http://localhost:3000"]
|
||||
114
services/dashboard/.github/copilot-instructions.md
vendored
Normal file
114
services/dashboard/.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,114 @@
|
||||
# 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 (`mabase_prod`), 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` | `mabase_prod` | 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`) |
|
||||
| `mabase_prod.audit_logs` | Audit trail (optional — missing table is handled silently) |
|
||||
86
services/dashboard/.gitignore
vendored
Normal file
86
services/dashboard/.gitignore
vendored
Normal file
@ -0,0 +1,86 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 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
|
||||
203
services/dashboard/AUDIT_SOC_DASHBOARD.md
Normal file
203
services/dashboard/AUDIT_SOC_DASHBOARD.md
Normal file
@ -0,0 +1,203 @@
|
||||
# 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é d’accès insuffisante** : pas d’authentification/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 n’existent 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 d’authentification et de RBAC** (confirmé aussi dans le README “usage local”).
|
||||
- Impact SOC : impossible d’attribuer correctement les actions analyste, risque d’accès non maîtrisé.
|
||||
|
||||
- **Injection potentielle dans `entities.py`** :
|
||||
- Construction d’un `IN (...)` SQL par concaténation de valeurs (`ip_values`), non paramétrée.
|
||||
- Impact : surface d’injection 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 d’insert 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 d’erreurs internes** :
|
||||
- Plusieurs endpoints retournent `detail=f"Erreur: {str(e)}"`.
|
||||
- Impact : divulgation d’informations 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 l’analyste.
|
||||
|
||||
|
||||
## Format des pages : ce qu’il 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 l’usage d’emojis 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 d’actions 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 l’information : recommandations
|
||||
|
||||
## IA) Repenser l’IA 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 d’activité lié à la page.
|
||||
|
||||
|
||||
## Plan d’amé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 l’auth, échec explicite si log non écrit).
|
||||
|
||||
## Phase 2 (fiabilité)
|
||||
|
||||
- Mettre en place rate limiting effectif.
|
||||
- Assainir gestion d’erreurs (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 l’investigation technique, mais pour un SOC opérationnel il faut d’abord :
|
||||
|
||||
1. **Sécuriser l’accè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).
|
||||
22
services/dashboard/Dockerfile
Normal file
22
services/dashboard/Dockerfile
Normal file
@ -0,0 +1,22 @@
|
||||
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
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
10
services/dashboard/Dockerfile.tests
Normal file
10
services/dashboard/Dockerfile.tests
Normal file
@ -0,0 +1,10 @@
|
||||
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"]
|
||||
242
services/dashboard/RAPPORT_FINAL.md
Normal file
242
services/dashboard/RAPPORT_FINAL.md
Normal file
@ -0,0 +1,242 @@
|
||||
# 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=30–31 | 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 :** 0–100 (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 48–57` → 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)
|
||||
- 1420–1452 → 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** : ~5–9 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 : **5–9s** (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.2–0.35 (pas 0–1 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).
|
||||
672
services/dashboard/README.md
Normal file
672
services/dashboard/README.md
Normal file
@ -0,0 +1,672 @@
|
||||
# 🛡️ 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 0–100%** : 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 0–100, 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` | `mabase_prod` | 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 |
|
||||
| 1420–1452 | 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 1440–1460, scale 3–5, 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 |
|
||||
|---|---|
|
||||
| `mabase_prod.ml_detected_anomalies` | metrics, detections, variability, analysis, clustering |
|
||||
| `mabase_prod.agg_host_ip_ja4_1h` | tcp_spoofing, clustering, investigation_summary |
|
||||
| `mabase_prod.view_dashboard_entities` | entities (UA, JA4, paths, query params) |
|
||||
| `mabase_prod.classifications` | analysis (classifications SOC manuelles) |
|
||||
| `mabase_prod.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 mabase_prod -q \
|
||||
"SELECT count() FROM ml_detected_anomalies WHERE detected_at >= now() - INTERVAL 24 HOUR"
|
||||
|
||||
# Voir un échantillon
|
||||
docker compose exec clickhouse clickhouse-client -d mabase_prod -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 mabase_prod -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 mabase_prod -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
|
||||
57
services/dashboard/ROUTES_NAVIGATION_PROGRESS.md
Normal file
57
services/dashboard/ROUTES_NAVIGATION_PROGRESS.md
Normal file
@ -0,0 +1,57 @@
|
||||
# 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 d’avancement
|
||||
|
||||
### É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 d’inté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 d’un 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.
|
||||
1
services/dashboard/backend/__init__.py
Normal file
1
services/dashboard/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Backend package
|
||||
27
services/dashboard/backend/config.py
Normal file
27
services/dashboard/backend/config.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""
|
||||
Configuration du Dashboard Bot Detector
|
||||
"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# ClickHouse
|
||||
CLICKHOUSE_HOST: str = "clickhouse"
|
||||
CLICKHOUSE_PORT: int = 8123
|
||||
CLICKHOUSE_DB: str = "mabase_prod"
|
||||
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:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
7
services/dashboard/backend/database.py
Normal file
7
services/dashboard/backend/database.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
ClickHouse connection — delegates to ja4_common shared client.
|
||||
"""
|
||||
from ja4_common.clickhouse import get_client as _get_client, ClickHouseClient
|
||||
|
||||
# Re-export for backward compatibility with existing route imports
|
||||
db: ClickHouseClient = _get_client()
|
||||
237
services/dashboard/backend/main.py
Normal file
237
services/dashboard/backend/main.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""
|
||||
Bot Detector Dashboard - API Backend
|
||||
FastAPI application pour servir le dashboard web
|
||||
"""
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, HTTPException
|
||||
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
|
||||
|
||||
# Configuration logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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 la base ClickHouse (`mabase_prod`) 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
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_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)
|
||||
|
||||
|
||||
# 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
|
||||
)
|
||||
322
services/dashboard/backend/models.py
Normal file
322
services/dashboard/backend/models.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""
|
||||
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):
|
||||
CRITICAL = "CRITICAL"
|
||||
HIGH = "HIGH"
|
||||
MEDIUM = "MEDIUM"
|
||||
LOW = "LOW"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# MÉTRIQUES
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class MetricsSummary(BaseModel):
|
||||
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):
|
||||
hour: datetime
|
||||
total: int
|
||||
critical: int
|
||||
high: int
|
||||
medium: int
|
||||
low: int
|
||||
|
||||
|
||||
class MetricsResponse(BaseModel):
|
||||
summary: MetricsSummary
|
||||
timeseries: List[TimeSeriesPoint]
|
||||
threat_distribution: Dict[str, int]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# DÉTECTIONS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Detection(BaseModel):
|
||||
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):
|
||||
items: List[Detection]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# VARIABILITÉ
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AttributeValue(BaseModel):
|
||||
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):
|
||||
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):
|
||||
type: str # "warning", "info", "success"
|
||||
message: str
|
||||
|
||||
|
||||
class VariabilityResponse(BaseModel):
|
||||
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):
|
||||
value: str
|
||||
count: int
|
||||
|
||||
|
||||
class AttributeListResponse(BaseModel):
|
||||
type: str
|
||||
items: List[AttributeListItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# USER-AGENTS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserAgentValue(BaseModel):
|
||||
value: str
|
||||
count: int
|
||||
percentage: float
|
||||
first_seen: Optional[datetime] = None
|
||||
last_seen: Optional[datetime] = None
|
||||
|
||||
|
||||
class UserAgentsResponse(BaseModel):
|
||||
type: str
|
||||
value: str
|
||||
user_agents: List[UserAgentValue]
|
||||
total: int
|
||||
showing: int
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# CLASSIFICATIONS (SOC / ML)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ClassificationLabel(str, Enum):
|
||||
LEGITIMATE = "legitimate"
|
||||
SUSPICIOUS = "suspicious"
|
||||
MALICIOUS = "malicious"
|
||||
|
||||
|
||||
class ClassificationBase(BaseModel):
|
||||
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):
|
||||
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)
|
||||
1
services/dashboard/backend/routes/__init__.py
Normal file
1
services/dashboard/backend/routes/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Routes package
|
||||
686
services/dashboard/backend/routes/analysis.py
Normal file
686
services/dashboard/backend/routes/analysis.py
Normal file
@ -0,0 +1,686 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
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 = """
|
||||
SELECT asn_number, asn_org
|
||||
FROM 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 = """
|
||||
SELECT DISTINCT src_ip
|
||||
FROM 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 = """
|
||||
SELECT uniq(src_ip)
|
||||
FROM 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 = """
|
||||
SELECT country_code, asn_number
|
||||
FROM 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 = """
|
||||
SELECT
|
||||
country_code,
|
||||
count() AS count
|
||||
FROM 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 = """
|
||||
SELECT
|
||||
country_code,
|
||||
count() AS count
|
||||
FROM 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 = """
|
||||
SELECT
|
||||
country_code,
|
||||
count() AS count
|
||||
FROM 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 = """
|
||||
SELECT ja4
|
||||
FROM 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 = """
|
||||
SELECT uniq(src_ip)
|
||||
FROM 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 = """
|
||||
SELECT
|
||||
src_ip,
|
||||
count() AS count
|
||||
FROM 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 = """
|
||||
SELECT DISTINCT ja4
|
||||
FROM 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 = """
|
||||
SELECT
|
||||
header_user_agent AS ua,
|
||||
count() AS count
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT count()
|
||||
FROM mabase_prod.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:
|
||||
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 = """
|
||||
INSERT INTO mabase_prod.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 mabase_prod.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 mabase_prod.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 mabase_prod.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 = """
|
||||
SELECT
|
||||
label,
|
||||
count() AS total,
|
||||
uniq(ip) AS unique_ips,
|
||||
avg(confidence) AS avg_confidence
|
||||
FROM mabase_prod.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)}")
|
||||
92
services/dashboard/backend/routes/attributes.py
Normal file
92
services/dashboard/backend/routes/attributes.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""
|
||||
Endpoints pour la liste des attributs uniques
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from ..database import db
|
||||
from ..models import AttributeListResponse, AttributeListItem
|
||||
|
||||
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 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 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)}")
|
||||
238
services/dashboard/backend/routes/audit.py
Normal file
238
services/dashboard/backend/routes/audit.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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 = """
|
||||
INSERT INTO mabase_prod.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 mabase_prod.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 = """
|
||||
SELECT
|
||||
action,
|
||||
count() AS count,
|
||||
uniq(user_name) AS unique_users,
|
||||
sum(entity_count) AS total_entities
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT
|
||||
user_name,
|
||||
count() AS actions,
|
||||
uniq(action) AS action_types,
|
||||
min(timestamp) AS first_action,
|
||||
max(timestamp) AS last_action
|
||||
FROM mabase_prod.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)}")
|
||||
105
services/dashboard/backend/routes/botnets.py
Normal file
105
services/dashboard/backend/routes/botnets.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""
|
||||
Endpoints pour l'analyse des botnets via la propagation des fingerprints JA4
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from ..database import db
|
||||
|
||||
router = APIRouter(prefix="/api/botnets", tags=["botnets"])
|
||||
|
||||
|
||||
def _botnet_class(unique_countries: int) -> str:
|
||||
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 = """
|
||||
SELECT
|
||||
ja4,
|
||||
unique_ips,
|
||||
unique_countries,
|
||||
targeted_hosts
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT
|
||||
src_country_code AS country_code,
|
||||
uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips,
|
||||
sum(hits) AS hits
|
||||
FROM mabase_prod.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 = """
|
||||
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 mabase_prod.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))
|
||||
141
services/dashboard/backend/routes/bruteforce.py
Normal file
141
services/dashboard/backend/routes/bruteforce.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""
|
||||
Endpoints pour l'analyse des attaques par force brute sur les formulaires
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from ..database import db
|
||||
|
||||
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 = """
|
||||
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 mabase_prod.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 = """
|
||||
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 mabase_prod.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 = """
|
||||
SELECT
|
||||
toHour(window_start) AS hour,
|
||||
sum(hits) AS hits,
|
||||
uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS ips
|
||||
FROM mabase_prod.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 = """
|
||||
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 mabase_prod.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))
|
||||
551
services/dashboard/backend/routes/clustering.py
Normal file
551
services/dashboard/backend/routes/clustering.py
Normal file
@ -0,0 +1,551 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
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 = """
|
||||
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 mabase_prod.agg_host_ip_ja4_1h t
|
||||
LEFT JOIN mabase_prod.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 mabase_prod.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 mabase_prod.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:
|
||||
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]:
|
||||
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 mabase_prod.agg_host_ip_ja4_1h t
|
||||
LEFT JOIN mabase_prod.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}
|
||||
450
services/dashboard/backend/routes/detections.py
Normal file
450
services/dashboard/backend/routes/detections.py
Normal file
@ -0,0 +1,450 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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 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 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 ml_detected_anomalies
|
||||
WHERE {where_clause}
|
||||
GROUP BY src_ip
|
||||
) ip_data
|
||||
LEFT JOIN mabase_prod.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 ml_detected_anomalies
|
||||
LEFT JOIN mabase_prod.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 = """
|
||||
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 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)}")
|
||||
509
services/dashboard/backend/routes/entities.py
Normal file
509
services/dashboard/backend/routes/entities.py
Normal file
@ -0,0 +1,509 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
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 = """
|
||||
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 mabase_prod.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 = """
|
||||
SELECT
|
||||
(SELECT groupUniqArray(toString(src_ip)) FROM mabase_prod.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 mabase_prod.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 mabase_prod.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 mabase_prod.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 mabase_prod.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 mabase_prod.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 = """
|
||||
WITH cleaned_ips AS (
|
||||
SELECT
|
||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
|
||||
detected_at,
|
||||
ja4,
|
||||
host,
|
||||
country_code,
|
||||
asn_number
|
||||
FROM 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 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 = """
|
||||
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 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 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)
|
||||
}
|
||||
827
services/dashboard/backend/routes/fingerprints.py
Normal file
827
services/dashboard/backend/routes/fingerprints.py
Normal file
@ -0,0 +1,827 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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 = """
|
||||
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 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 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 = """
|
||||
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 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 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 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 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 = """
|
||||
SELECT
|
||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS clean_ip,
|
||||
avg(ua_ch_mismatch) AS avg_ua_ch_mismatch
|
||||
FROM 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:
|
||||
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 = """
|
||||
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 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 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 = """
|
||||
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 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 = """
|
||||
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 mabase_prod.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)}")
|
||||
101
services/dashboard/backend/routes/header_fingerprint.py
Normal file
101
services/dashboard/backend/routes/header_fingerprint.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""
|
||||
Endpoints pour l'analyse des empreintes d'en-têtes HTTP
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from ..database import db
|
||||
|
||||
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 = """
|
||||
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 mabase_prod.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 = """
|
||||
SELECT uniq(header_order_hash)
|
||||
FROM mabase_prod.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 = """
|
||||
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 mabase_prod.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))
|
||||
144
services/dashboard/backend/routes/heatmap.py
Normal file
144
services/dashboard/backend/routes/heatmap.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""
|
||||
Endpoints pour la heatmap temporelle (hits par heure / hôte)
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from ..database import db
|
||||
|
||||
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 = """
|
||||
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 mabase_prod.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 = """
|
||||
SELECT
|
||||
host,
|
||||
sum(hits) AS total_hits,
|
||||
uniq(replaceRegexpAll(toString(src_ip), '^::ffff:', '')) AS unique_ips,
|
||||
uniq(ja4) AS unique_ja4s
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT
|
||||
host,
|
||||
toHour(window_start) AS hour,
|
||||
sum(hits) AS hits
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT host, sum(hits) AS total_hits
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT
|
||||
host,
|
||||
toHour(window_start) AS hour,
|
||||
sum(hits) AS hits
|
||||
FROM mabase_prod.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))
|
||||
266
services/dashboard/backend/routes/incidents.py
Normal file
266
services/dashboard/backend/routes/incidents.py
Normal file
@ -0,0 +1,266 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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 = """
|
||||
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 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 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 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 = """
|
||||
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 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)}")
|
||||
185
services/dashboard/backend/routes/investigation_summary.py
Normal file
185
services/dashboard/backend/routes/investigation_summary.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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 = """
|
||||
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 mabase_prod.ml_detected_anomalies
|
||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(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 = """
|
||||
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 mabase_prod.view_form_bruteforce_detected
|
||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(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 = """
|
||||
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 mabase_prod.agg_host_ip_ja4_1h
|
||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(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 = """
|
||||
SELECT distinct_ja4_count, total_hits
|
||||
FROM mabase_prod.view_host_ip_ja4_rotation
|
||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(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 = """
|
||||
SELECT recurrence, worst_score, worst_threat_level, first_seen, last_seen
|
||||
FROM mabase_prod.view_ip_recurrence
|
||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(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 = """
|
||||
SELECT
|
||||
toHour(window_start) AS hour,
|
||||
sum(hits) AS hits,
|
||||
groupUniqArray(3)(ja4) AS ja4s
|
||||
FROM mabase_prod.agg_host_ip_ja4_1h
|
||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(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))
|
||||
175
services/dashboard/backend/routes/metrics.py
Normal file
175
services/dashboard/backend/routes/metrics.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""
|
||||
Endpoints pour les métriques du dashboard
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ..database import db
|
||||
from ..models import MetricsResponse, MetricsSummary, TimeSeriesPoint
|
||||
|
||||
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 = """
|
||||
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 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 = """
|
||||
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 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 = """
|
||||
SELECT
|
||||
threat_level,
|
||||
count() AS count,
|
||||
round(count() * 100.0 / sum(count()) OVER (), 2) AS percentage
|
||||
FROM 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 = """
|
||||
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 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:
|
||||
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)}")
|
||||
425
services/dashboard/backend/routes/ml_features.py
Normal file
425
services/dashboard/backend/routes/ml_features.py
Normal file
@ -0,0 +1,425 @@
|
||||
"""
|
||||
Endpoints pour les features ML / IA (scores d'anomalies, radar, scatter)
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from ..database import db
|
||||
|
||||
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:
|
||||
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 = """
|
||||
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('mabase_prod.dict_asn_reputation', 'label', toUInt64(any(a.src_asn)), 'unknown') AS asn_label,
|
||||
coalesce(
|
||||
nullIf(dictGetOrDefault('mabase_prod.dict_bot_ja4', 'bot_name', tuple(any(a.ja4)), ''), ''),
|
||||
''
|
||||
) AS bot_name
|
||||
FROM mabase_prod.agg_host_ip_ja4_1h a
|
||||
LEFT JOIN mabase_prod.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 = """
|
||||
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 mabase_prod.view_ai_features_1h
|
||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(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:
|
||||
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 = """
|
||||
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 mabase_prod.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 = """
|
||||
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 mabase_prod.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 = """
|
||||
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 mabase_prod.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 = """
|
||||
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 mabase_prod.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 = """
|
||||
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 mabase_prod.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 = """
|
||||
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 mabase_prod.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))
|
||||
125
services/dashboard/backend/routes/reputation.py
Normal file
125
services/dashboard/backend/routes/reputation.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""
|
||||
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)}"
|
||||
)
|
||||
217
services/dashboard/backend/routes/rotation.py
Normal file
217
services/dashboard/backend/routes/rotation.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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 = """
|
||||
SELECT
|
||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||
distinct_ja4_count,
|
||||
total_hits
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT
|
||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||
recurrence,
|
||||
worst_score,
|
||||
worst_threat_level,
|
||||
first_seen,
|
||||
last_seen
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT
|
||||
ja4,
|
||||
sum(hits) AS hits,
|
||||
min(window_start) AS first_seen,
|
||||
max(window_start) AS last_seen
|
||||
FROM mabase_prod.agg_host_ip_ja4_1h
|
||||
WHERE replaceRegexpAll(toString(src_ip), '^::ffff:', '') = %(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 = """
|
||||
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 mabase_prod.view_host_ip_ja4_rotation
|
||||
) r
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||
count() AS recurrence
|
||||
FROM mabase_prod.ml_detected_anomalies FINAL
|
||||
GROUP BY ip
|
||||
) rec ON r.ip = rec.ip
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
replaceRegexpAll(toString(src_ip), '^::ffff:', '') AS ip,
|
||||
sum(hits) AS bruteforce_hits
|
||||
FROM mabase_prod.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 = """
|
||||
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 mabase_prod.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))
|
||||
125
services/dashboard/backend/routes/search.py
Normal file
125
services/dashboard/backend/routes/search.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""
|
||||
Endpoint de recherche globale rapide — utilisé par la barre Cmd+K
|
||||
"""
|
||||
from fastapi import APIRouter, Query
|
||||
from ..database import db
|
||||
|
||||
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(
|
||||
"""
|
||||
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 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(
|
||||
"""
|
||||
SELECT
|
||||
ja4,
|
||||
count() AS hits,
|
||||
uniq(src_ip) AS unique_ips
|
||||
FROM 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(
|
||||
"""
|
||||
SELECT
|
||||
host,
|
||||
count() AS hits,
|
||||
uniq(src_ip) AS unique_ips
|
||||
FROM 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(
|
||||
"""
|
||||
SELECT
|
||||
asn_org,
|
||||
asn_number,
|
||||
count() AS hits,
|
||||
uniq(src_ip) AS unique_ips
|
||||
FROM 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}
|
||||
223
services/dashboard/backend/routes/tcp_spoofing.py
Normal file
223
services/dashboard/backend/routes/tcp_spoofing.py
Normal file
@ -0,0 +1,223 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
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 = """
|
||||
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 mabase_prod.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 = """
|
||||
SELECT tcp_ttl_raw AS ttl, count() AS cnt, uniq(src_ip) AS ips
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT tcp_mss_raw AS mss, count() AS cnt, uniq(src_ip) AS ips
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT tcp_win_raw AS win, count() AS cnt
|
||||
FROM mabase_prod.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 = """
|
||||
SELECT count() FROM (
|
||||
SELECT src_ip, ja4
|
||||
FROM mabase_prod.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 = """
|
||||
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 mabase_prod.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 = """
|
||||
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 mabase_prod.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))
|
||||
706
services/dashboard/backend/routes/variability.py
Normal file
706
services/dashboard/backend/routes/variability.py
Normal file
@ -0,0 +1,706 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
|
||||
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 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 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 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 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 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 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 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 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 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 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 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 mabase_prod.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 mabase_prod.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 mabase_prod.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 mabase_prod.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 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 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)}")
|
||||
0
services/dashboard/backend/services/__init__.py
Normal file
0
services/dashboard/backend/services/__init__.py
Normal file
493
services/dashboard/backend/services/clustering_engine.py
Normal file
493
services/dashboard/backend/services/clustering_engine.py
Normal file
@ -0,0 +1,493 @@
|
||||
"""
|
||||
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)
|
||||
312
services/dashboard/backend/services/reputation_ip.py
Normal file
312
services/dashboard/backend/services/reputation_ip.py
Normal file
@ -0,0 +1,312 @@
|
||||
"""
|
||||
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
|
||||
436
services/dashboard/backend/services/tcp_fingerprint.py
Normal file
436
services/dashboard/backend/services/tcp_fingerprint.py
Normal file
@ -0,0 +1,436 @@
|
||||
"""
|
||||
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 (0–1)
|
||||
# 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")
|
||||
0
services/dashboard/backend/tests/__init__.py
Normal file
0
services/dashboard/backend/tests/__init__.py
Normal file
18
services/dashboard/backend/tests/conftest.py
Normal file
18
services/dashboard/backend/tests/conftest.py
Normal file
@ -0,0 +1,18 @@
|
||||
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
|
||||
10
services/dashboard/backend/tests/test_audit.py
Normal file
10
services/dashboard/backend/tests/test_audit.py
Normal file
@ -0,0 +1,10 @@
|
||||
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)
|
||||
70
services/dashboard/backend/tests/test_detections.py
Normal file
70
services/dashboard/backend/tests/test_detections.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""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)
|
||||
26
services/dashboard/backend/tests/test_health.py
Normal file
26
services/dashboard/backend/tests/test_health.py
Normal file
@ -0,0 +1,26 @@
|
||||
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
|
||||
34
services/dashboard/backend/tests/test_metrics.py
Normal file
34
services/dashboard/backend/tests/test_metrics.py
Normal file
@ -0,0 +1,34 @@
|
||||
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)
|
||||
25
services/dashboard/backend/tests/test_reputation.py
Normal file
25
services/dashboard/backend/tests/test_reputation.py
Normal file
@ -0,0 +1,25 @@
|
||||
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")
|
||||
32
services/dashboard/docker-compose.yaml
Normal file
32
services/dashboard/docker-compose.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
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:-mabase_prod}
|
||||
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
|
||||
|
||||
13
services/dashboard/frontend/index.html
Normal file
13
services/dashboard/frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!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>
|
||||
33
services/dashboard/frontend/package.json
Normal file
33
services/dashboard/frontend/package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
6
services/dashboard/frontend/postcss.config.js
Normal file
6
services/dashboard/frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
436
services/dashboard/frontend/src/App.tsx
Normal file
436
services/dashboard/frontend/src/App.tsx
Normal file
@ -0,0 +1,436 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
74
services/dashboard/frontend/src/ThemeContext.tsx
Normal file
74
services/dashboard/frontend/src/ThemeContext.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
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);
|
||||
}
|
||||
152
services/dashboard/frontend/src/api/client.ts
Normal file
152
services/dashboard/frontend/src/api/client.ts
Normal file
@ -0,0 +1,152 @@
|
||||
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)}`),
|
||||
};
|
||||
465
services/dashboard/frontend/src/components/BruteForceView.tsx
Normal file
465
services/dashboard/frontend/src/components/BruteForceView.tsx
Normal file
@ -0,0 +1,465 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,295 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
1072
services/dashboard/frontend/src/components/CampaignsView.tsx
Normal file
1072
services/dashboard/frontend/src/components/CampaignsView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
734
services/dashboard/frontend/src/components/ClusteringView.tsx
Normal file
734
services/dashboard/frontend/src/components/ClusteringView.tsx
Normal file
@ -0,0 +1,734 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
585
services/dashboard/frontend/src/components/CorrelationGraph.tsx
Normal file
585
services/dashboard/frontend/src/components/CorrelationGraph.tsx
Normal file
@ -0,0 +1,585 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
153
services/dashboard/frontend/src/components/DetailsView.tsx
Normal file
153
services/dashboard/frontend/src/components/DetailsView.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
598
services/dashboard/frontend/src/components/DetectionsList.tsx
Normal file
598
services/dashboard/frontend/src/components/DetectionsList.tsx
Normal file
@ -0,0 +1,598 @@
|
||||
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));
|
||||
}
|
||||
@ -0,0 +1,397 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
1944
services/dashboard/frontend/src/components/FingerprintsView.tsx
Normal file
1944
services/dashboard/frontend/src/components/FingerprintsView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,333 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
567
services/dashboard/frontend/src/components/IncidentsView.tsx
Normal file
567
services/dashboard/frontend/src/components/IncidentsView.tsx
Normal file
@ -0,0 +1,567 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,376 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
524
services/dashboard/frontend/src/components/InvestigationView.tsx
Normal file
524
services/dashboard/frontend/src/components/InvestigationView.tsx
Normal file
@ -0,0 +1,524 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,336 @@
|
||||
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);
|
||||
}
|
||||
559
services/dashboard/frontend/src/components/MLFeaturesView.tsx
Normal file
559
services/dashboard/frontend/src/components/MLFeaturesView.tsx
Normal file
@ -0,0 +1,559 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
365
services/dashboard/frontend/src/components/PivotView.tsx
Normal file
365
services/dashboard/frontend/src/components/PivotView.tsx
Normal file
@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
182
services/dashboard/frontend/src/components/QuickSearch.tsx
Normal file
182
services/dashboard/frontend/src/components/QuickSearch.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
216
services/dashboard/frontend/src/components/ReputationPanel.tsx
Normal file
216
services/dashboard/frontend/src/components/ReputationPanel.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,262 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
573
services/dashboard/frontend/src/components/TcpSpoofingView.tsx
Normal file
573
services/dashboard/frontend/src/components/TcpSpoofingView.tsx
Normal file
@ -0,0 +1,573 @@
|
||||
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 & 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>
|
||||
);
|
||||
}
|
||||
326
services/dashboard/frontend/src/components/ThreatIntelView.tsx
Normal file
326
services/dashboard/frontend/src/components/ThreatIntelView.tsx
Normal file
@ -0,0 +1,326 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
258
services/dashboard/frontend/src/components/VariabilityPanel.tsx
Normal file
258
services/dashboard/frontend/src/components/VariabilityPanel.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,289 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,348 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,120 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
26
services/dashboard/frontend/src/components/ui/Card.tsx
Normal file
26
services/dashboard/frontend/src/components/ui/Card.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
165
services/dashboard/frontend/src/components/ui/DataTable.tsx
Normal file
165
services/dashboard/frontend/src/components/ui/DataTable.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
26
services/dashboard/frontend/src/components/ui/Feedback.tsx
Normal file
26
services/dashboard/frontend/src/components/ui/Feedback.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
35
services/dashboard/frontend/src/components/ui/StatCard.tsx
Normal file
35
services/dashboard/frontend/src/components/ui/StatCard.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
145
services/dashboard/frontend/src/components/ui/Tooltip.tsx
Normal file
145
services/dashboard/frontend/src/components/ui/Tooltip.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
415
services/dashboard/frontend/src/components/ui/tooltips.ts
Normal file
415
services/dashboard/frontend/src/components/ui/tooltips.ts
Normal file
@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 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 45–70 %\nActivité suspecte. Investigation recommandée.',
|
||||
|
||||
risk_medium:
|
||||
'MEDIUM — Score 25–45 %\nComportement anormal. Surveillance renforcée.',
|
||||
|
||||
risk_low:
|
||||
'LOW — Score < 25 %\nTrafic probablement légitime.',
|
||||
|
||||
// ── Sidebar cluster ─────────────────────────────────────────────────────────
|
||||
|
||||
risk_score:
|
||||
'Score composite [0–100 %] 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 ≈ 1380–1420 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 ≈ 1380–1420 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 [0–100 %].\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 ≈ 10–15 headers.\n' +
|
||||
'Bot HTTP basique ≈ 2–5 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 45–70 %\n' +
|
||||
'· MEDIUM 25–45 % · LOW < 25 %',
|
||||
|
||||
// ── Nouveau ──────────────────────────────────────────────────────────────────
|
||||
|
||||
risk_score_inv:
|
||||
'Score de risque composite [0–100] 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 [0–100].\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 [0–100] : 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 [0–100 %].\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 3–10 pays.\n' +
|
||||
'Peut indiquer un réseau de proxies régionaux ou une campagne ciblée.',
|
||||
|
||||
botnet_concentrated:
|
||||
'Botnet Concentré : IPs majoritairement dans 1–2 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',
|
||||
};
|
||||
39
services/dashboard/frontend/src/config.ts
Normal file
39
services/dashboard/frontend/src/config.ts
Normal file
@ -0,0 +1,39 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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;
|
||||
|
||||
52
services/dashboard/frontend/src/hooks/useDetections.ts
Normal file
52
services/dashboard/frontend/src/hooks/useDetections.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { detectionsApi, DetectionsListResponse } from '../api/client';
|
||||
|
||||
interface UseDetectionsParams {
|
||||
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;
|
||||
}
|
||||
|
||||
export function useDetections(params: UseDetectionsParams = {}) {
|
||||
const [data, setData] = useState<DetectionsListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetections = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await detectionsApi.getDetections(params);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetections();
|
||||
}, [
|
||||
params.page,
|
||||
params.page_size,
|
||||
params.threat_level,
|
||||
params.model_name,
|
||||
params.country_code,
|
||||
params.asn_number,
|
||||
params.search,
|
||||
params.sort_by,
|
||||
params.sort_order,
|
||||
params.group_by_ip,
|
||||
params.score_type,
|
||||
]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
57
services/dashboard/frontend/src/hooks/useFetch.ts
Normal file
57
services/dashboard/frontend/src/hooks/useFetch.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface FetchState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook générique pour les appels fetch avec gestion loading/error.
|
||||
* Annule automatiquement la requête si le composant est démonté
|
||||
* ou si l'URL change avant que la réponse arrive.
|
||||
*
|
||||
* @param url URL relative ou absolue à appeler (typiquement "/api/...")
|
||||
* @param deps Dépendances supplémentaires qui déclenchent un re-fetch
|
||||
* (en plus de url). Équivalent au tableau de deps de useEffect.
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useFetch<MyType>('/api/metrics');
|
||||
*/
|
||||
export function useFetch<T>(url: string, deps: unknown[] = []): FetchState<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
return res.json() as Promise<T>;
|
||||
})
|
||||
.then((json) => {
|
||||
if (!cancelled) {
|
||||
setData(json);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, ...deps]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
28
services/dashboard/frontend/src/hooks/useMetrics.ts
Normal file
28
services/dashboard/frontend/src/hooks/useMetrics.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { metricsApi, MetricsResponse } from '../api/client';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
export function useMetrics() {
|
||||
const [data, setData] = useState<MetricsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const response = await metricsApi.getMetrics();
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetrics();
|
||||
const interval = setInterval(fetchMetrics, CONFIG.METRICS_REFRESH_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
42
services/dashboard/frontend/src/hooks/useSort.ts
Normal file
42
services/dashboard/frontend/src/hooks/useSort.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
export function useSort<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
defaultKey: keyof T,
|
||||
defaultDir: SortDir = 'desc'
|
||||
): {
|
||||
sorted: T[];
|
||||
sortKey: keyof T;
|
||||
sortDir: SortDir;
|
||||
handleSort: (key: keyof T) => void;
|
||||
} {
|
||||
const [sortKey, setSortKey] = useState<keyof T>(defaultKey);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(defaultDir);
|
||||
|
||||
const handleSort = (key: keyof T) => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...data].sort((a, b) => {
|
||||
const av = a[sortKey];
|
||||
const bv = b[sortKey];
|
||||
let cmp = 0;
|
||||
if (av == null && bv == null) cmp = 0;
|
||||
else if (av == null) cmp = 1;
|
||||
else if (bv == null) cmp = -1;
|
||||
else if (typeof av === 'number' && typeof bv === 'number') cmp = av - bv;
|
||||
else cmp = String(av).localeCompare(String(bv));
|
||||
return sortDir === 'desc' ? -cmp : cmp;
|
||||
});
|
||||
}, [data, sortKey, sortDir]);
|
||||
|
||||
return { sorted, sortKey, sortDir, handleSort };
|
||||
}
|
||||
30
services/dashboard/frontend/src/hooks/useVariability.ts
Normal file
30
services/dashboard/frontend/src/hooks/useVariability.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { variabilityApi, VariabilityResponse } from '../api/client';
|
||||
|
||||
export function useVariability(type: string, value: string) {
|
||||
const [data, setData] = useState<VariabilityResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!type || !value) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchVariability = async () => {
|
||||
try {
|
||||
const response = await variabilityApi.getVariability(type, value);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Erreur inconnue'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVariability();
|
||||
}, [type, value]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
13
services/dashboard/frontend/src/main.tsx
Normal file
13
services/dashboard/frontend/src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { ThemeProvider } from './ThemeContext'
|
||||
import './styles/globals.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
59
services/dashboard/frontend/src/styles/globals.css
Normal file
59
services/dashboard/frontend/src/styles/globals.css
Normal file
@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Dark theme (default, SOC standard) ── */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-bg: 15 23 42; /* Slate 900 */
|
||||
--color-bg-secondary: 30 41 59; /* Slate 800 */
|
||||
--color-bg-card: 51 65 85; /* Slate 700 */
|
||||
--color-text-primary: 248 250 252;/* Slate 50 */
|
||||
--color-text-secondary:148 163 184;/* Slate 400 */
|
||||
--color-text-disabled: 100 116 139;/* Slate 500 */
|
||||
--scrollbar-track: #1e293b;
|
||||
--scrollbar-thumb: #475569;
|
||||
--scrollbar-thumb-hover: #64748b;
|
||||
--border-color: rgba(148,163,184,0.12);
|
||||
}
|
||||
|
||||
/* ── Light theme ── */
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--color-bg: 241 245 249;/* Slate 100 */
|
||||
--color-bg-secondary: 255 255 255;/* White */
|
||||
--color-bg-card: 226 232 240;/* Slate 200 */
|
||||
--color-text-primary: 15 23 42; /* Slate 900 */
|
||||
--color-text-secondary:71 85 105; /* Slate 600 */
|
||||
--color-text-disabled: 148 163 184;/* Slate 400 */
|
||||
--scrollbar-track: #f1f5f9;
|
||||
--scrollbar-thumb: #cbd5e1;
|
||||
--scrollbar-thumb-hover: #94a3b8;
|
||||
--border-color: rgba(15,23,42,0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||||
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes pulse-red { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||
|
||||
.animate-fade-in { animation: fadeIn 0.25s ease-in-out; }
|
||||
.animate-slide-up { animation: slideUp 0.35s ease-out; }
|
||||
305
services/dashboard/frontend/src/utils/STIXExporter.ts
Normal file
305
services/dashboard/frontend/src/utils/STIXExporter.ts
Normal file
@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Export STIX 2.1 pour Threat Intelligence
|
||||
* Format standard pour l'échange d'informations de cybermenaces
|
||||
*/
|
||||
|
||||
interface STIXIndicator {
|
||||
id: string;
|
||||
type: string;
|
||||
spec_version: string;
|
||||
created: string;
|
||||
modified: string;
|
||||
name: string;
|
||||
description: string;
|
||||
pattern: string;
|
||||
pattern_type: string;
|
||||
valid_from: string;
|
||||
labels: string[];
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface STIXObservables {
|
||||
id: string;
|
||||
type: string;
|
||||
spec_version: string;
|
||||
value?: string;
|
||||
hashes?: {
|
||||
MD5?: string;
|
||||
'SHA-256'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface STIXBundle {
|
||||
type: string;
|
||||
id: string;
|
||||
objects: (STIXIndicator | STIXObservables)[];
|
||||
}
|
||||
|
||||
export class STIXExporter {
|
||||
/**
|
||||
* Génère un bundle STIX 2.1 à partir d'une liste d'IPs
|
||||
*/
|
||||
static exportIPs(ips: string[], metadata: {
|
||||
label: string;
|
||||
tags: string[];
|
||||
confidence: number;
|
||||
analyst: string;
|
||||
comment: string;
|
||||
}): STIXBundle {
|
||||
const now = new Date().toISOString();
|
||||
const objects: (STIXIndicator | STIXObservables)[] = [];
|
||||
|
||||
// Identity (organisation SOC)
|
||||
objects.push({
|
||||
id: `identity--${this.generateUUID()}`,
|
||||
type: 'identity',
|
||||
spec_version: '2.1',
|
||||
name: 'SOC Bot Detector',
|
||||
identity_class: 'system',
|
||||
created: now,
|
||||
modified: now
|
||||
} as any);
|
||||
|
||||
// Create indicators and observables for each IP
|
||||
ips.forEach((ip) => {
|
||||
const indicatorId = `indicator--${this.generateUUID()}`;
|
||||
const observableId = `ipv4-addr--${this.generateUUID()}`;
|
||||
|
||||
// STIX Indicator
|
||||
objects.push({
|
||||
id: indicatorId,
|
||||
type: 'indicator',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
name: `Malicious IP - ${ip}`,
|
||||
description: `${metadata.comment} | Tags: ${metadata.tags.join(', ')} | Analyst: ${metadata.analyst}`,
|
||||
pattern: `[ipv4-addr:value = '${ip}']`,
|
||||
pattern_type: 'stix',
|
||||
valid_from: now,
|
||||
labels: [...metadata.tags, metadata.label],
|
||||
confidence: Math.round(metadata.confidence * 100),
|
||||
created_by_ref: objects[0].id,
|
||||
object_marking_refs: [`marking-definition--${this.generateUUID()}`]
|
||||
} as STIXIndicator);
|
||||
|
||||
// STIX Observable (IPv4 Address)
|
||||
objects.push({
|
||||
id: observableId,
|
||||
type: 'ipv4-addr',
|
||||
spec_version: '2.1',
|
||||
value: ip,
|
||||
object_marking_refs: [`marking-definition--${this.generateUUID()}`]
|
||||
} as STIXObservables);
|
||||
|
||||
// Relationship between indicator and observable
|
||||
objects.push({
|
||||
id: `relationship--${this.generateUUID()}`,
|
||||
type: 'relationship',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
relationship_type: 'indicates',
|
||||
source_ref: indicatorId,
|
||||
target_ref: observableId,
|
||||
description: 'Indicator indicates malicious IP address'
|
||||
} as any);
|
||||
});
|
||||
|
||||
// Marking Definition (TLP:AMBER)
|
||||
objects.push({
|
||||
id: 'marking-definition--78ca4366-f5b8-4764-83f7-34ce38198e27',
|
||||
type: 'marking-definition',
|
||||
spec_version: '2.1',
|
||||
name: 'TLP:AMBER',
|
||||
created: '2017-01-20T00:00:00.000Z',
|
||||
definition_type: 'statement',
|
||||
definition: { statement: 'This information is TLP:AMBER' }
|
||||
} as any);
|
||||
|
||||
return {
|
||||
type: 'bundle',
|
||||
id: `bundle--${this.generateUUID()}`,
|
||||
objects
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un bundle STIX pour un incident complet
|
||||
*/
|
||||
static exportIncident(incident: {
|
||||
id: string;
|
||||
subnet: string;
|
||||
ips: string[];
|
||||
ja4?: string;
|
||||
severity: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}): STIXBundle {
|
||||
const now = new Date().toISOString();
|
||||
const objects: any[] = [];
|
||||
|
||||
// Identity
|
||||
objects.push({
|
||||
id: `identity--${this.generateUUID()}`,
|
||||
type: 'identity',
|
||||
spec_version: '2.1',
|
||||
name: 'SOC Bot Detector',
|
||||
identity_class: 'system',
|
||||
created: now,
|
||||
modified: now
|
||||
});
|
||||
|
||||
// Incident
|
||||
objects.push({
|
||||
id: `incident--${this.generateUUID()}`,
|
||||
type: 'incident',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
name: `Bot Detection Incident ${incident.id}`,
|
||||
description: incident.description,
|
||||
objective: 'Detect and classify bot activity',
|
||||
first_seen: incident.first_seen,
|
||||
last_seen: incident.last_seen,
|
||||
status: 'active',
|
||||
labels: [...incident.tags, incident.severity]
|
||||
});
|
||||
|
||||
// Campaign (for the attack pattern)
|
||||
objects.push({
|
||||
id: `campaign--${this.generateUUID()}`,
|
||||
type: 'campaign',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
name: `Bot Campaign - ${incident.subnet}`,
|
||||
description: `Automated bot activity from subnet ${incident.subnet}`,
|
||||
first_seen: incident.first_seen,
|
||||
last_seen: incident.last_seen,
|
||||
labels: incident.tags
|
||||
});
|
||||
|
||||
// Relationship: Campaign uses Attack Pattern
|
||||
objects.push({
|
||||
id: `relationship--${this.generateUUID()}`,
|
||||
type: 'relationship',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
relationship_type: 'related-to',
|
||||
source_ref: objects[objects.length - 1].id, // campaign
|
||||
target_ref: objects[objects.length - 2].id // incident
|
||||
});
|
||||
|
||||
// Add indicators for each IP
|
||||
incident.ips.slice(0, 100).forEach(ip => {
|
||||
const indicatorId = `indicator--${this.generateUUID()}`;
|
||||
|
||||
objects.push({
|
||||
id: indicatorId,
|
||||
type: 'indicator',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
name: `Malicious IP - ${ip}`,
|
||||
description: `Part of incident ${incident.id}`,
|
||||
pattern: `[ipv4-addr:value = '${ip}']`,
|
||||
pattern_type: 'stix',
|
||||
valid_from: now,
|
||||
labels: incident.tags,
|
||||
confidence: 80
|
||||
});
|
||||
|
||||
// Relationship: Incident indicates IP
|
||||
objects.push({
|
||||
id: `relationship--${this.generateUUID()}`,
|
||||
type: 'relationship',
|
||||
spec_version: '2.1',
|
||||
created: now,
|
||||
modified: now,
|
||||
relationship_type: 'related-to',
|
||||
source_ref: objects[objects.length - 2].id, // incident
|
||||
target_ref: indicatorId
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'bundle',
|
||||
id: `bundle--${this.generateUUID()}`,
|
||||
objects
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Télécharge le bundle STIX
|
||||
*/
|
||||
static download(bundle: STIXBundle, filename?: string): void {
|
||||
const json = JSON.stringify(bundle, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `stix_export_${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un UUID v4
|
||||
*/
|
||||
private static generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export au format MISP (alternative à STIX)
|
||||
*/
|
||||
static exportMISP(ips: string[], metadata: any): object {
|
||||
return {
|
||||
response: {
|
||||
Event: {
|
||||
id: this.generateUUID(),
|
||||
orgc: 'SOC Bot Detector',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
threat_level_id: metadata.label === 'malicious' ? '1' :
|
||||
metadata.label === 'suspicious' ? '2' : '3',
|
||||
analysis: '2', // Completed
|
||||
info: `Bot Detection: ${metadata.comment}`,
|
||||
uuid: this.generateUUID(),
|
||||
Attribute: ips.map((ip) => ({
|
||||
type: 'ip-dst',
|
||||
category: 'Network activity',
|
||||
value: ip,
|
||||
to_ids: true,
|
||||
uuid: this.generateUUID(),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
comment: `${metadata.tags.join(', ')} | Confidence: ${metadata.confidence}`
|
||||
})),
|
||||
Tag: metadata.tags.map((tag: string) => ({
|
||||
name: tag,
|
||||
colour: this.getTagColor(tag)
|
||||
}))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static getTagColor(tag: string): string {
|
||||
// Generate consistent colors for tags
|
||||
const colors = [
|
||||
'#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4',
|
||||
'#ffeaa7', '#dfe6e9', '#fd79a8', '#a29bfe'
|
||||
];
|
||||
const hash = tag.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
}
|
||||
36
services/dashboard/frontend/src/utils/classifications.ts
Normal file
36
services/dashboard/frontend/src/utils/classifications.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Tags prédéfinis pour la classification SOC.
|
||||
*
|
||||
* Utilisé par BulkClassification, CorrelationSummary, JA4CorrelationSummary.
|
||||
* Ajouter de nouveaux tags ici pour les propager partout.
|
||||
*/
|
||||
export const PREDEFINED_TAGS: readonly string[] = [
|
||||
'scraping',
|
||||
'bot-network',
|
||||
'scanner',
|
||||
'bruteforce',
|
||||
'data-exfil',
|
||||
'ddos',
|
||||
'spam',
|
||||
'proxy',
|
||||
'tor',
|
||||
'vpn',
|
||||
'hosting-asn',
|
||||
'distributed',
|
||||
'ja4-rotation',
|
||||
'ua-rotation',
|
||||
'country-cn',
|
||||
'country-us',
|
||||
'country-ru',
|
||||
];
|
||||
|
||||
/**
|
||||
* Tags supplémentaires spécifiques aux fingerprints JA4.
|
||||
* S'étend de PREDEFINED_TAGS.
|
||||
*/
|
||||
export const PREDEFINED_TAGS_JA4: readonly string[] = [
|
||||
...PREDEFINED_TAGS,
|
||||
'known-bot',
|
||||
'crawler',
|
||||
'search-engine',
|
||||
];
|
||||
11
services/dashboard/frontend/src/utils/countryUtils.ts
Normal file
11
services/dashboard/frontend/src/utils/countryUtils.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Convertit un code pays ISO 3166-1 alpha-2 en emoji drapeau.
|
||||
* Utilise les Regional Indicator Symbols Unicode (U+1F1E6…U+1F1FF).
|
||||
* Retourne 🌐 pour les codes invalides ou vides.
|
||||
*/
|
||||
export function getCountryFlag(code: string): string {
|
||||
if (!code || code.length !== 2) return '🌐';
|
||||
return code
|
||||
.toUpperCase()
|
||||
.replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397));
|
||||
}
|
||||
86
services/dashboard/frontend/src/utils/dateUtils.ts
Normal file
86
services/dashboard/frontend/src/utils/dateUtils.ts
Normal file
@ -0,0 +1,86 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Utilitaires de formatage des dates et des nombres
|
||||
//
|
||||
// Les dates sont stockées en UTC dans ClickHouse (sans suffixe TZ).
|
||||
// Ces fonctions les convertissent dans le fuseau horaire local du navigateur
|
||||
// et utilisent la locale du navigateur pour l'affichage.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalise une chaîne datetime ClickHouse (sans TZ) en Date UTC.
|
||||
* ClickHouse retourne "2024-01-15 14:32:00" → on force Z pour UTC.
|
||||
*/
|
||||
function parseUTC(iso: string): Date {
|
||||
if (!iso) return new Date(NaN);
|
||||
// Déjà un ISO complet avec TZ → pas de modification
|
||||
if (iso.endsWith('Z') || iso.includes('+')) return new Date(iso);
|
||||
// "2024-01-15 14:32:00" ou "2024-01-15T14:32:00" → forcer UTC
|
||||
const normalized = iso.replace(' ', 'T');
|
||||
return new Date(normalized + 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date/heure complète dans la locale et le fuseau du navigateur.
|
||||
* Exemple : "15/01/2024, 15:32" (fr) ou "1/15/2024, 3:32 PM" (en-US)
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
const d = parseUTC(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(navigator.language || undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate une date courte (jour/mois heure:min) pour les tableaux.
|
||||
* Exemple : "15/01 15:32"
|
||||
*/
|
||||
export function formatDateShort(iso: string): string {
|
||||
const d = parseUTC(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString(navigator.language || undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate uniquement la partie date.
|
||||
* Exemple : "15/01/2024"
|
||||
*/
|
||||
export function formatDateOnly(iso: string): string {
|
||||
const d = parseUTC(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(navigator.language || undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate uniquement l'heure (heure:min).
|
||||
* Exemple : "15:32"
|
||||
*/
|
||||
export function formatTimeOnly(iso: string): string {
|
||||
const d = parseUTC(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleTimeString(navigator.language || undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate un nombre entier avec séparateurs de milliers selon la locale du navigateur.
|
||||
* Exemple : 1234567 → "1 234 567" (fr) ou "1,234,567" (en-US)
|
||||
*/
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString(navigator.language || undefined);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user