diff --git a/.gitignore b/.gitignore index dce003e..7a786a1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ lab/clab-*/ .claude/ .factory/ +*.yaml.bak diff --git a/AGENTS.md b/AGENTS.md index bebeae3..53364dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ src/ ├── auth/ # AuthBackend (axum-login), mode-aware auth (none/bearer/credentials/mtls) ├── bgp/ # FlowSpecAnnouncer trait, GoBGP gRPC client, mock ├── config/ # Settings, Inventory, Playbooks (YAML parsing) +├── correlation/ # Multi-signal correlation engine (config, engine, signal groups) ├── db/ # PostgreSQL repository with sqlx + MockRepository for testing ├── domain/ # Core types: AttackEvent, Mitigation, FlowSpecRule ├── guardrails/ # Validation, quotas, safelist protection @@ -70,7 +71,8 @@ frontend/ │ │ ├── audit-log/ # Audit trail │ │ ├── config/ # Settings (JSON) + Playbooks (cards) + hot-reload │ │ ├── admin/ # Tabbed: System Status, Safelist CRUD, User management -│ │ └── ip-history/ # IP history timeline with search +│ │ ├── ip-history/ # IP history timeline with search +│ │ └── correlation/ # Correlation dashboard (Signals, Groups, Config tabs) + group detail │ ├── login/ # Login page (outside auth guard) │ ├── globals.css # Light + dark theme variables │ └── layout.tsx # Root layout with ThemeProvider + Toaster @@ -92,17 +94,18 @@ frontend/ ├── vitest.config.ts # Vitest config (jsdom, react plugin, @ alias) └── vitest.setup.ts # jest-dom matchers -configs/ # prefixd.yaml, inventory.yaml, playbooks.yaml, nginx.conf, gobgp.conf +configs/ # prefixd.yaml, inventory.yaml, playbooks.yaml, correlation.yaml, nginx.conf, gobgp.conf docs/ ├── api.md # Full API reference with examples ├── deployment.md # Docker + nginx deployment guide -└── adr/ # 17 Architecture Decision Records (001-017) +├── configuration.md # Full configuration reference +└── adr/ # 19 Architecture Decision Records (001-019) grafana/ # Prometheus config, Grafana provisioning, dashboard JSON tests/ -├── integration.rs # 44 integration tests (health, config, mitigations, events, filters, bulk withdraw, cursor pagination, bulk acknowledge, per-dest routing, preferences, event batch, incident reports) -├── integration_e2e.rs # 6 end-to-end tests (ignored without Docker) +├── integration.rs # 99 integration tests (health, config, mitigations, events, filters, bulk withdraw, cursor pagination, bulk acknowledge, per-dest routing, preferences, event batch, incident reports, signal groups, correlation, signal adapters) +├── integration_e2e.rs # 9 end-to-end tests (ignored without Docker) ├── integration_gobgp.rs # 8 tests (GoBGP integration, ignored without GoBGP) -└── integration_postgres.rs # 9 integration tests (Postgres-backed flows) +└── integration_postgres.rs # 16 integration tests (Postgres-backed flows, signal groups) ``` ## Key Design Decisions @@ -119,7 +122,7 @@ tests/ 10. **Route-group auth guard** - Next.js `(dashboard)/layout.tsx` wraps all protected pages 11. **Mode-aware auth** - `none`/`bearer`/`credentials`/`mtls` with role checks on protected endpoints -See `docs/adr/` for all 17 Architecture Decision Records. +See `docs/adr/` for all 19 Architecture Decision Records. ## API Endpoints @@ -157,12 +160,19 @@ See `docs/adr/` for all 17 Architecture Decision Records. - `GET/POST /v1/operators` - User management (admin only) - `DELETE /v1/operators/{id}` - Delete user (admin only) - `PUT /v1/operators/{id}/password` - Change password (admin only) +- `GET /v1/signal-groups` - List signal groups (with pagination, status/vector/date filters) +- `GET /v1/signal-groups/{id}` - Signal group detail with contributing events +- `POST /v1/signals/alertmanager` - Alertmanager webhook adapter (v4 payload) +- `POST /v1/signals/fastnetmon` - FastNetMon webhook adapter (native JSON) +- `GET /v1/config/correlation` - Correlation config (admin, secrets redacted) +- `PUT /v1/config/correlation` - Update correlation config (admin only, writes YAML + hot-reload) ## Data Flow 1. **Event Ingestion** (`POST /v1/events`) - Validate input, check duplicates - Lookup IP context from inventory + - Correlate signals (if `correlation.enabled`): find/create signal group, check corroboration - Evaluate playbook for vector - Check guardrails (TTL, /32, quotas, safelist) - Create or extend mitigation @@ -184,10 +194,10 @@ See `docs/adr/` for all 17 Architecture Decision Records. ## Testing ```bash -# Backend unit tests (126 tests) +# Backend unit tests (179 tests) cargo test -# All backend tests including integration (179 runnable: 126 unit + 44 integration + 9 postgres; 14 ignored requiring GoBGP/Docker) +# All backend tests including integration (294 runnable: 179 unit + 99 integration + 16 postgres; 17 ignored requiring GoBGP/Docker) cargo test --features test-utils # Lint @@ -210,6 +220,7 @@ cargo run -- --config ./configs - `configs/prefixd.yaml` - Main daemon config - `configs/inventory.yaml` - Customer/service/IP mapping - `configs/playbooks.yaml` - Vector → action policies +- `configs/correlation.yaml` - Correlation engine config (sources, weights, thresholds) - `configs/nginx.conf` - Reverse proxy config - `configs/gobgp.conf` - GoBGP BGP config @@ -243,11 +254,12 @@ Completed: - Nginx reverse proxy (single-origin deployment) - ErrorBoundary wrapping all dashboard pages - Cross-entity navigation (command palette → detail pages, event↔mitigation linking, audit log → mitigations, clickable stat cards) -- 17 Architecture Decision Records +- Multi-signal correlation engine with signal groups, Alertmanager and FastNetMon adapters +- 19 Architecture Decision Records - CLI tool (prefixdctl) for all API operations - OpenAPI spec with utoipa annotations -- 126 backend unit tests + 53 integration tests (+ 14 ignored requiring GoBGP/Docker) -- Vitest + Testing Library frontend test infrastructure (34 tests) +- 179 backend unit tests + 99 integration + 16 postgres tests (+ 17 ignored requiring GoBGP/Docker) +- Vitest + Testing Library frontend test infrastructure (64 tests) ## Code Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd4583..301574b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to prefixd will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Multi-signal correlation engine** — Time-windowed grouping of related attack events by (victim_ip, vector) from multiple detection sources. Configurable source weights, corroboration thresholds, and per-playbook overrides. When `correlation.enabled` is true, events are grouped into signal groups and mitigation only triggers when corroboration requirements are met (configurable `min_sources` and `confidence_threshold`). Single-source behavior is preserved with `min_sources=1` (backward compatible). See [ADR 018](docs/adr/018-multi-signal-correlation-engine.md). +- **Signal groups API** — `GET /v1/signal-groups` (list with cursor pagination, status/vector/date filters) and `GET /v1/signal-groups/{id}` (detail with contributing events, source weights, and confidence). Both endpoints require authentication. +- **Correlation context on mitigations** — `GET /v1/mitigations` and `GET /v1/mitigations/{id}` responses include a `correlation` field for correlated mitigations, containing signal_group_id, derived_confidence, source_count, corroboration_met, contributing_sources, and a human-readable explanation. +- **Correlation engine metrics** — `prefixd_signal_groups_total`, `prefixd_signal_group_sources`, `prefixd_correlation_confidence`, `prefixd_corroboration_met_total`, `prefixd_corroboration_timeout_total` Prometheus counters and histograms. +- **Signal group expiry** — Reconciliation loop expires open signal groups whose time window has elapsed, transitioning them to `expired` status. +- **Database migration 007** — `signal_groups` and `signal_group_events` tables, `mitigations.signal_group_id` nullable FK column with indexes. +- **Correlation configuration** — New `correlation` section in `prefixd.yaml` with `enabled`, `window_seconds`, `min_sources`, `confidence_threshold`, `sources` (per-source weight/type), and `default_weight`. Per-playbook `correlation` overrides in `playbooks.yaml`. Hot-reloadable via `POST /v1/config/reload`. +- **Alertmanager webhook adapter** — `POST /v1/signals/alertmanager` accepts Alertmanager v4 webhook payloads. Maps labels/annotations to attack event fields (vector, victim_ip, bps/pps, severity→confidence). Handles batched alerts with per-alert results, resolved alerts (→ withdraw), fingerprint dedup. Returns 400 for malformed payloads (Alertmanager won't retry 4xx). See [ADR 019](docs/adr/019-signal-adapter-architecture.md). +- **FastNetMon webhook adapter** — `POST /v1/signals/fastnetmon` accepts FastNetMon's native JSON notify payload. Classifies attack vector from traffic breakdown (UDP/SYN/ICMP/TCP), maps action type to confidence (ban=0.9, partial_block=0.7, alert=0.5, configurable), uses `attack_uuid` for dedup. Returns `EventResponse` shape for script compatibility. +- **Correlation config API** — `GET /v1/config/correlation` (secrets redacted) and `PUT /v1/config/correlation` (admin only, validates, writes YAML, hot-reloads). Correlation config reloaded alongside inventory/playbooks/alerting on `POST /v1/config/reload`. +- **Signal adapter E2E tests** — 3 end-to-end tests in `tests/integration_e2e.rs` verifying full-stack signal adapter flows through real Postgres and GoBGP: Alertmanager→signal group→mitigation, FastNetMon→signal group→mitigation, multi-source corroboration (FastNetMon + Alertmanager → same group → mitigation with FlowSpec in RIB). Marked `#[ignore]` by default (require Docker). + +### Changed + +- Backend unit tests increased from 126 to 179 (correlation engine, config parsing, corroboration, explainability, signal adapters) +- Integration tests increased from 44 to 99 (signal group CRUD, correlation flow, concurrent event handling, Alertmanager adapter, FastNetMon adapter, correlation config API) +- Postgres integration tests increased from 9 to 16 (signal group operations) +- Frontend tests increased from 34 to 67 (correlation dashboard, signal group detail, mitigation detail correlation) + ## [0.13.0] - 2026-03-19 ### Added diff --git a/FEATURES.md b/FEATURES.md index 381e95f..123120a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -21,8 +21,9 @@ Comprehensive list of prefixd capabilities. Any system that can POST JSON works. Tested with: - **FastNetMon Community** - Notify script integration ([setup guide](docs/detectors/fastnetmon.md)) -- **Prometheus/Alertmanager** - Via webhook receiver -- **Custom scripts** - Simple curl calls +- **FastNetMon** (native webhook) - `POST /v1/signals/fastnetmon` accepts FastNetMon's JSON payload directly, classifies vector from traffic breakdown, configurable confidence mapping +- **Prometheus/Alertmanager** - `POST /v1/signals/alertmanager` accepts Alertmanager v4 webhook payloads, maps labels/annotations to event fields, handles batched alerts with per-alert results +- **Custom scripts** - Simple curl calls to `POST /v1/events` ### Event Schema @@ -241,6 +242,72 @@ Real-time events pushed to dashboard: --- +## Multi-Signal Correlation + +Combine weak signals from multiple detectors into high-confidence mitigation decisions. Example: FastNetMon reports a UDP flood at 0.6 confidence + Alertmanager fires a bandwidth alert = corroborated high-confidence mitigation. + +### Signal Groups + +Events targeting the same (victim_ip, vector) within a time window are grouped into a **signal group**. Each group tracks: + +- Contributing events from multiple sources +- Derived confidence (weighted by source reliability) +- Corroboration status (whether the `min_sources` threshold is met) +- Source breakdown with per-source confidence and weight + +### Corroboration Model + +- **min_sources** - Minimum number of distinct sources required before mitigation triggers (default: 1 for backward compatibility) +- **confidence_threshold** - Minimum derived confidence to trigger mitigation +- **Per-playbook overrides** - Different thresholds per attack vector +- **Time-windowed** - Signal groups expire after `window_seconds` if corroboration is not met + +### Source Weighting + +Each detection source is assigned a weight reflecting its reliability: + +```yaml +correlation: + enabled: true + window_seconds: 120 + min_sources: 2 + confidence_threshold: 0.7 + sources: + fastnetmon: + weight: 1.0 + type: detector + alertmanager: + weight: 0.8 + type: alert + default_weight: 0.5 +``` + +### Explainability + +Every correlated mitigation includes a `correlation` field explaining the decision: + +- Signal group ID and contributing sources +- Per-source confidence and weight +- Whether corroboration was met +- Human-readable explanation string + +### Signal Groups API + +- `GET /v1/signal-groups` - List groups with cursor pagination, status/vector/date filters +- `GET /v1/signal-groups/{id}` - Detail with contributing events, source weights, and confidence +- `GET /v1/config/correlation` - Current correlation config (secrets redacted) +- `PUT /v1/config/correlation` - Update config (admin only, validates, writes YAML, hot-reloads) + +### Correlation Dashboard + +- **Signals tab** - Recent events with source, confidence, and group assignment +- **Groups tab** - Signal groups with status, source count, confidence, corroboration status +- **Config tab** - Visual correlation config editor with source weights +- **Group detail page** - Contributing events, source breakdown, timeline +- **Mitigation detail integration** - Correlation context section on mitigated IPs + +--- + ## Inventory ### Customer/IP Mapping diff --git a/README.md b/README.md index 15528b7..b0b53b4 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,9 @@ playbooks: ### 3. Connect a detector -Point your detector at prefixd's API: +Point your detector at prefixd's API. Three integration paths: +**Generic events API** (any detector): ```bash curl -X POST http://localhost/v1/events \ -H "Content-Type: application/json" \ @@ -165,6 +166,12 @@ curl -X POST http://localhost/v1/events \ }' ``` +**Native adapters** (zero-config signal translation): +- **Alertmanager** → `POST /v1/signals/alertmanager` — maps labels/annotations to events +- **FastNetMon** → `POST /v1/signals/fastnetmon` — accepts native JSON payload + +With [multi-signal correlation](docs/configuration.md#correlation) enabled, events from multiple detectors targeting the same IP are grouped and corroborated before triggering mitigation. + See [FastNetMon Integration](docs/detectors/fastnetmon.md) for a complete setup guide. ### 4. Peer with your routers @@ -177,7 +184,8 @@ Configure GoBGP neighbors in `configs/gobgp.conf` and set up FlowSpec import pol | Category | What it does | |----------|--------------| -| **Signal Ingestion** | HTTP API accepts attack events from any detector | +| **Signal Ingestion** | HTTP API + native Alertmanager and FastNetMon webhook adapters | +| **Multi-Signal Correlation** | Time-windowed grouping of events from multiple detectors with source weighting and corroboration | | **Policy Engine** | YAML playbooks define per-vector responses with escalation | | **Guardrails** | Quotas, safelist, /32-only enforcement, mandatory TTLs | | **BGP FlowSpec** | Announces via GoBGP (traffic-rate, discard actions) | @@ -192,13 +200,14 @@ Configure GoBGP neighbors in `configs/gobgp.conf` and set up FlowSpec import pol ## How It Works -1. **Detector sends event** → `POST /v1/events` with victim IP, vector, confidence +1. **Detector sends event** → `POST /v1/events`, `/v1/signals/alertmanager`, or `/v1/signals/fastnetmon` 2. **Inventory lookup** → Find customer/service owning the IP -3. **Playbook match** → Determine action (police/discard) based on vector -4. **Guardrails check** → Validate quotas, safelist, prefix length -5. **FlowSpec announce** → Send rule to GoBGP via gRPC -6. **Router enforcement** → Traffic filtered at line rate -7. **Auto-expiry** → Rule withdrawn when TTL expires +3. **Signal correlation** → Group related signals by (victim_ip, vector), check corroboration +4. **Playbook match** → Determine action (police/discard) based on vector +5. **Guardrails check** → Validate quotas, safelist, prefix length +6. **FlowSpec announce** → Send rule to GoBGP via gRPC +7. **Router enforcement** → Traffic filtered at line rate +8. **Auto-expiry** → Rule withdrawn when TTL expires **Fail-open design:** If prefixd dies, mitigations auto-expire. No permanent rules, no stuck state. @@ -263,7 +272,7 @@ Current version: **v0.13.0** - **Issues:** [GitHub Issues](https://github.com/lance0/prefixd/issues) - **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) -- **Architecture Decision Records:** [docs/adr/](docs/adr/) +- **Architecture Decision Records:** [docs/adr/](docs/adr/) (19 ADRs) --- diff --git a/ROADMAP.md b/ROADMAP.md index b57674f..aca0891 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -274,25 +274,25 @@ Replace the GoBGP container dependency with [rustbgpd](https://github.com/lance0 Example: FastNetMon says UDP flood at 0.6 confidence + router CPU spiking + host conntrack exhaustion = **high-confidence mitigation**. -### Signal Adapters (start with one) +### Signal Adapters -- [ ] Prometheus/Alertmanager adapter (metric queries, webhook receiver) — most universal, many operators already have this -- [ ] Enhanced FastNetMon adapter (configurable confidence mapping) — common pairing for self-hosted +- [x] Prometheus/Alertmanager adapter (`POST /v1/signals/alertmanager` webhook receiver) — maps labels/annotations to attack events, handles batched alerts +- [x] FastNetMon webhook adapter (`POST /v1/signals/fastnetmon`) — classifies vectors from traffic breakdown, configurable confidence mapping - [ ] Router telemetry adapter (JTI, gNMI) ### Correlation Engine -- [ ] Time-windowed event grouping -- [ ] Source weighting and reliability scoring -- [ ] Corroboration requirements ("require 2+ sources") -- [ ] Correlation explainability (`why` details in API/UI for each mitigation decision) +- [x] Time-windowed event grouping +- [x] Source weighting and reliability scoring +- [x] Corroboration requirements ("require 2+ sources") +- [x] Correlation explainability (`why` details in API/UI for each mitigation decision) - [ ] Replay mode for tuning (simulate historical incidents without announcing FlowSpec rules) ### Confidence Model -- [ ] Derived confidence from traffic patterns +- [x] Derived confidence from traffic patterns - [ ] Confidence decay over time -- [ ] Per-playbook thresholds +- [x] Per-playbook thresholds --- diff --git a/configs/correlation.yaml b/configs/correlation.yaml new file mode 100644 index 0000000..b382278 --- /dev/null +++ b/configs/correlation.yaml @@ -0,0 +1,18 @@ +enabled: true +window_seconds: 300 +min_sources: 1 +confidence_threshold: 0.5 +sources: + dashboard: + weight: 1.0 + type: manual + confidence_mapping: {} + fastnetmon: + weight: 1.0 + type: detector + confidence_mapping: {} + alertmanager: + weight: 0.8 + type: telemetry + confidence_mapping: {} +default_weight: 1.0 diff --git a/configs/prefixd.yaml b/configs/prefixd.yaml index 2541bf8..6f3b3aa 100644 --- a/configs/prefixd.yaml +++ b/configs/prefixd.yaml @@ -61,6 +61,22 @@ safelist: - "10.0.0.0/8" - "192.168.0.0/16" +correlation: + enabled: true + window_seconds: 300 + min_sources: 1 + confidence_threshold: 0.5 + sources: + fastnetmon: + weight: 1.0 + type: detector + alertmanager: + weight: 0.8 + type: telemetry + dashboard: + weight: 1.0 + type: manual + shutdown: drain_timeout_seconds: 30 preserve_announcements: true diff --git a/docker-compose.yml b/docker-compose.yml index b876451..4292328 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: expose: - "8080" volumes: - - ./configs:/etc/prefixd:ro + - ./configs:/etc/prefixd - prefixd-data:/data environment: - RUST_LOG=info diff --git a/docs/adr/018-multi-signal-correlation-engine.md b/docs/adr/018-multi-signal-correlation-engine.md new file mode 100644 index 0000000..753a04f --- /dev/null +++ b/docs/adr/018-multi-signal-correlation-engine.md @@ -0,0 +1,94 @@ +# ADR 018: Multi-Signal Correlation Engine + +## Status + +Accepted + +## Date + +2026-03-19 + +## Context + +prefixd currently treats each detector event independently: a single `POST /v1/events` creates a mitigation if it passes guardrails and matches a playbook. This works well for high-confidence detectors like FastNetMon in ban mode, but creates two problems: + +1. **Low-confidence signals go to waste.** A telemetry-based alert at 0.5 confidence is too weak to act on alone, even though it carries useful information. If two independent sources both flag the same victim and vector, the combined evidence is much stronger than either signal alone. + +2. **No corroboration path.** Operators who integrate multiple detection sources (NetFlow analyzers, Alertmanager rules, FastNetMon, manual reports) have no way to require agreement between sources before triggering a mitigation. They either set low thresholds (false positives) or high thresholds (missed attacks). + +The correlation engine addresses this by grouping related signals within a configurable time window and computing a weighted confidence score across sources. Corroboration — requiring a minimum number of distinct sources — becomes an optional, per-playbook policy lever. + +### Alternatives Considered + +1. **Client-side aggregation.** Have detectors pre-aggregate before calling the API. Rejected because it pushes complexity to every integration and prevents cross-source corroboration. + +2. **Event deduplication only.** Extend the existing `EventCorrelator` (scope-matching by ports) to track sources. Rejected because scope-matching serves a different purpose (extending TTL on same-scope mitigations) and conflating the two concepts makes both harder to reason about. + +3. **External stream processor (Kafka/Flink).** Powerful but introduces significant operational complexity for what is fundamentally a small-cardinality grouping problem (unique victim_ip × vector × time window). The in-process approach keeps the deployment simple. + +## Decision + +### 1. Time-windowed grouping by (victim_ip, vector) + +When an event arrives with correlation enabled, the engine looks for an existing **signal group** with matching `(victim_ip, vector)` whose window has not yet expired. If found, the event joins that group. If not, a new group is created with `window_expires_at = now + correlation.window_seconds`. + +This is the simplest grouping key that captures "multiple sources agreeing about the same attack." Port-level granularity is deliberately omitted from grouping — different detectors may report different top ports for the same DDoS vector, and requiring port-exact matches would defeat corroboration. + +### 2. Weighted confidence aggregation + +Each signal source has a configurable weight (default 1.0). The derived confidence for a signal group is the weighted average: + +``` +derived_confidence = Σ(event_confidence_i × source_weight_i) / Σ(source_weight_i) +``` + +This allows operators to express trust levels: a FastNetMon ban (weight 2.0) contributes more to derived confidence than a Prometheus alert rule (weight 0.8). + +### 3. Optional corroboration with backward compatibility + +The `min_sources` parameter (default 1) controls how many distinct sources must contribute before a signal group can trigger a mitigation: + +- **min_sources=1** (default): A single event from any source can trigger a mitigation if its confidence meets the threshold. This preserves current behavior — existing deployments see no change. +- **min_sources=2+**: Requires corroboration. A single source's event is recorded in the signal group but does not trigger a mitigation until additional sources confirm. + +Per-playbook overrides allow operators to require corroboration for some vectors (e.g., UDP floods from noisy detectors) while keeping single-source triggering for others (e.g., SYN floods from a trusted detector). + +### 4. Integration point: between event storage and policy evaluation + +The correlation step is inserted after the event is persisted (ensuring no data loss) and before policy evaluation (ensuring corroboration is checked before any mitigation decision). When correlation is disabled (`enabled: false`), this step is skipped entirely — the code path is identical to v0.13.0. + +### 5. Database-backed signal groups + +Signal groups are stored in PostgreSQL (`signal_groups` and `signal_group_events` tables) rather than in-memory. This ensures: + +- Groups survive prefixd restarts during the correlation window. +- The reconciliation loop can expire stale groups. +- Multiple prefixd instances (future) share the same group state. +- Full auditability of which events contributed to each mitigation decision. + +A nullable `signal_group_id` column on the `mitigations` table links each mitigation to the signal group that triggered it, enabling end-to-end explainability. + +### 6. Configuration in prefixd.yaml with hot-reload + +Correlation configuration lives in the main `prefixd.yaml` under a `correlation:` section. Using `#[serde(default)]` ensures omitting the section entirely produces a disabled (backward-compatible) config. Configuration changes are picked up on `POST /v1/config/reload` without restarting the daemon. + +## Consequences + +### Positive + +- Operators can combine weak signals from multiple detectors into high-confidence mitigation decisions. +- Backward compatible: existing single-detector deployments work unchanged (min_sources=1, correlation disabled by default). +- Per-playbook overrides give fine-grained control over which attack vectors require corroboration. +- Database-backed groups provide full auditability and survive restarts. +- Weighted confidence lets operators tune trust levels per detection source. + +### Negative + +- Adds latency to the ingestion path when correlation is enabled (database lookup for existing group + insert/update). Mitigated by indexes on `(victim_ip, vector, status)`. +- Increases database write volume (one signal_group_events row per event). Acceptable given the expected event rates (tens to low hundreds per minute). +- When min_sources > 1, there is a window where an attack is detected but not yet mitigated (waiting for corroboration). Operators must understand this trade-off. + +### Neutral + +- The existing `EventCorrelator` in `src/policy/correlation.rs` (scope-matching) remains unchanged. It serves a different purpose (TTL extension for same-scope mitigations) and operates independently of multi-signal correlation. +- Signal group expiry is handled by the existing reconciliation loop, adding minimal new complexity to the scheduler. diff --git a/docs/adr/019-signal-adapter-architecture.md b/docs/adr/019-signal-adapter-architecture.md new file mode 100644 index 0000000..bca1199 --- /dev/null +++ b/docs/adr/019-signal-adapter-architecture.md @@ -0,0 +1,89 @@ +# ADR 019: Signal Adapter Architecture + +## Status + +Accepted + +## Date + +2026-03-19 + +## Context + +prefixd needs to ingest signals from multiple detection and telemetry systems beyond its existing `POST /v1/events` endpoint. The first external integrations are Alertmanager (Prometheus alerting) and FastNetMon (dedicated DDoS detector). Each system has its own payload format, label conventions, and lifecycle semantics (e.g., Alertmanager sends resolved alerts; FastNetMon uses ban/unban actions). + +Key questions: + +1. **Push vs. pull** — Should prefixd poll external systems for alerts, or should external systems push webhooks to prefixd? +2. **Dedicated endpoints vs. generic** — Should we reuse `POST /v1/events` with adapter-specific fields, or create dedicated endpoints per signal source? +3. **Label mapping** — How should source-specific labels (e.g., Alertmanager's `labels.severity`, `labels.instance`) map to prefixd's internal `AttackEventInput` fields? +4. **Batching** — Alertmanager sends batched alerts in a single webhook call. How should partial failures be handled? +5. **Extensibility** — How easy should it be to add a new signal adapter? + +## Decision + +### Webhook receivers (push-in model) + +We use **push-in webhooks** — external systems push alerts to dedicated prefixd endpoints. This avoids coupling prefixd to external system APIs, avoids polling overhead, and matches how Alertmanager and FastNetMon natively deliver notifications. + +### Dedicated endpoints per signal source + +Each signal source gets its own endpoint under `/v1/signals/{source}`: + +- `POST /v1/signals/alertmanager` — Alertmanager v4 webhook format +- `POST /v1/signals/fastnetmon` — FastNetMon native notify format + +We chose dedicated endpoints over reusing `/v1/events` because: + +- **Type safety** — Each adapter validates the source-specific payload schema at the HTTP boundary, returning 400 for malformed input (critical for Alertmanager, which won't retry 4xx errors). +- **Clear contracts** — Each endpoint documents exactly what fields are expected from that source, with source-specific defaults (e.g., Alertmanager severity → confidence mapping). +- **Independent evolution** — Adapters can evolve their payload acceptance independently without affecting the core events API. +- **Dedup semantics** — Each source has its own dedup key (Alertmanager uses `fingerprint`, FastNetMon uses its own). + +### Internal reuse of event ingestion pipeline + +Despite having separate HTTP endpoints, all adapters convert their source-specific payload into `AttackEventInput` and delegate to the existing `handle_ban()` / `handle_unban()` internal functions. This ensures: + +- Correlation engine integration (signal groups, source weighting) +- Guardrail checks (safelist, TTL, quotas) +- Policy evaluation (playbook matching) +- BGP announcement/withdrawal +- Audit trail and WebSocket broadcast + +### Label mapping pattern + +Each adapter defines a deterministic mapping from source-specific labels to `AttackEventInput` fields: + +| AttackEventInput field | Alertmanager source | Fallback | +|---|---|---| +| `vector` | `labels.vector` | `labels.alertname` | +| `victim_ip` | `labels.victim_ip` | `labels.instance` (port stripped) | +| `bps` | `annotations.bps` (parsed as i64) | None | +| `pps` | `annotations.pps` (parsed as i64) | None | +| `confidence` | `labels.severity` mapped (critical=0.9, warning=0.7, info=0.5) | 0.5 | +| `action` | `alerts[].status` ("resolved" → "unban", else "ban") | "ban" | +| `event_id` | `alerts[].fingerprint` | None | +| `source` | hardcoded `"alertmanager"` | — | + +### Per-alert error handling + +Alertmanager sends batched alerts. Each alert is processed independently — a failure in one alert does not abort the batch. The response includes per-alert results with status and optional error messages. The overall HTTP status is always 200 (for well-formed payloads) to prevent Alertmanager from retrying the entire batch. + +## Consequences + +### Positive + +- **Simple integration** — Configure Alertmanager's `webhook_configs` receiver to point at `/v1/signals/alertmanager` and alerts flow into the correlation engine. +- **Type-safe parsing** — Source-specific payloads are validated at ingestion, giving clear error messages for misconfiguration. +- **Extensible** — Adding a new signal adapter is a self-contained task: define the payload struct, write the mapping function, add the handler and route. +- **Correlation-ready** — All adapters feed into the same signal group mechanism, enabling cross-source corroboration (e.g., Alertmanager + FastNetMon signals for the same victim_ip strengthen confidence). + +### Negative + +- **Endpoint proliferation** — Each new signal source requires a new endpoint. Mitigated by the consistent `/v1/signals/{source}` pattern and reuse of internal pipeline. +- **Mapping maintenance** — Label mappings need documentation and testing for each source. Mitigated by integration tests covering all mapping variants. + +### Neutral + +- **Authentication** — Signal adapter endpoints require the same authentication as other API endpoints (bearer token or session). Operators must configure their external systems with appropriate credentials. +- **Source identification** — Each adapter sets a hardcoded `source` name (e.g., "alertmanager"), which feeds into the correlation engine's per-source weight configuration. diff --git a/docs/adr/README.md b/docs/adr/README.md index efa7fef..4bc6996 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,5 +25,7 @@ Format follows [Michael Nygard's template](https://cognitect.com/blog/2011/11/15 | [015](015-health-endpoint-split.md) | Split health endpoint (public liveness + authenticated detail) | Accepted | 2026-02-18 | | [016](016-cursor-pagination.md) | Cursor-Based Pagination (Replacing Offset) | Accepted | 2026-03-18 | | [017](017-notification-routing-preferences.md) | Per-Destination Event Routing and Notification Preferences | Accepted | 2026-03-18 | +| [018](018-multi-signal-correlation-engine.md) | Multi-Signal Correlation Engine | Accepted | 2026-03-19 | +| [019](019-signal-adapter-architecture.md) | Signal Adapter Architecture | Accepted | 2026-03-19 | ADRs are numbered sequentially as written. Retroactive ADRs (009-013) were documented on 2026-02-18 but dated to when the decision was originally made. diff --git a/docs/api.md b/docs/api.md index 952a150..d186b28 100644 --- a/docs/api.md +++ b/docs/api.md @@ -281,7 +281,8 @@ Authorization: Bearer "last_event_id": "550e8400-e29b-41d4-a716-446655440000", "reason": "Vector policy: udp_flood", "acknowledged_at": null, - "acknowledged_by": null + "acknowledged_by": null, + "correlation": null } ], "count": 1, @@ -290,6 +291,23 @@ Authorization: Bearer } ``` +When a mitigation was created via multi-source corroboration (correlation engine enabled), the `correlation` field contains context about the decision: + +```json +{ + "correlation": { + "signal_group_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "derived_confidence": 0.75, + "source_count": 2, + "corroboration_met": true, + "contributing_sources": ["fastnetmon", "alertmanager"], + "explanation": "Corroboration met: 2 distinct source(s) (min=2) with derived confidence 0.75 (threshold=0.50). Sources: fastnetmon(conf=0.90, w=1.0), alertmanager(conf=0.60, w=0.8)" + } +} +``` + +When correlation is disabled or the mitigation was created by a single source without corroboration, the `correlation` field is `null` or absent. + ### Create Mitigation ```http @@ -346,7 +364,16 @@ Returns the full mitigation object (same shape as [Get Mitigation](#get-mitigati GET /v1/mitigations/{id} ``` -**Response:** Same as list item. +**Response:** Same as list item, including the `correlation` field when present. For correlated mitigations, the correlation object includes: + +| Field | Type | Description | +|-------|------|-------------| +| `signal_group_id` | UUID | Signal group that triggered this mitigation | +| `derived_confidence` | float | Weighted average confidence from contributing events | +| `source_count` | integer | Number of distinct detection sources | +| `corroboration_met` | boolean | Whether corroboration threshold was met | +| `contributing_sources` | array | List of source names that contributed | +| `explanation` | string | Human-readable explanation of the correlation decision | ### Withdraw Mitigation @@ -459,6 +486,357 @@ Acknowledging marks a mitigation as reviewed by a human without changing its sta --- +## Signal Groups + +Signal groups are created by the correlation engine when `correlation.enabled` is true. They group related attack events by (victim_ip, vector) within a configurable time window, enabling multi-source corroboration. + +### List Signal Groups + +```http +GET /v1/signal-groups +Authorization: Bearer +``` + +**Query Parameters:** + +| Param | Type | Description | +|-------|------|-------------| +| `status` | string | Filter by status: `open`, `resolved`, `expired` | +| `vector` | string | Filter by attack vector | +| `limit` | integer | Max results (default 100, max 1000) | +| `cursor` | string | Cursor for pagination (from previous response `next_cursor`) | +| `start` | string | Start of date range (ISO 8601, inclusive) | +| `end` | string | End of date range (ISO 8601, exclusive) | + +**Response:** + +```json +{ + "groups": [ + { + "group_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "victim_ip": "203.0.113.10", + "vector": "udp_flood", + "created_at": "2026-03-19T10:30:00Z", + "window_expires_at": "2026-03-19T10:35:00Z", + "derived_confidence": 0.75, + "source_count": 2, + "status": "resolved", + "corroboration_met": true + } + ], + "count": 1, + "next_cursor": null, + "has_more": false +} +``` + +**Signal Group Status:** + +| Status | Description | +|--------|-------------| +| `open` | Accepting new events within the time window | +| `resolved` | Corroboration met and mitigation created | +| `expired` | Time window elapsed without sufficient corroboration | + +### Get Signal Group Detail + +```http +GET /v1/signal-groups/{id} +Authorization: Bearer +``` + +Returns group metadata and all contributing events with source, confidence, and source weight. + +**Response:** + +```json +{ + "group_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "victim_ip": "203.0.113.10", + "vector": "udp_flood", + "created_at": "2026-03-19T10:30:00Z", + "window_expires_at": "2026-03-19T10:35:00Z", + "derived_confidence": 0.75, + "source_count": 2, + "status": "resolved", + "corroboration_met": true, + "events": [ + { + "group_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "event_id": "550e8400-e29b-41d4-a716-446655440000", + "source_weight": 1.0, + "source": "fastnetmon", + "confidence": 0.9, + "ingested_at": "2026-03-19T10:30:01Z" + }, + { + "group_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "event_id": "660e8400-e29b-41d4-a716-446655440001", + "source_weight": 0.8, + "source": "alertmanager", + "confidence": 0.6, + "ingested_at": "2026-03-19T10:31:15Z" + } + ] +} +``` + +**Error Responses:** + +| Status | Reason | +|--------|--------| +| 401 | Authentication required | +| 404 | Signal group not found | + +--- + +## Signal Adapters + +Signal adapter endpoints accept webhooks from external detection and telemetry systems, translate their payloads into `AttackEventInput`, and feed them into the standard event ingestion pipeline (including correlation, guardrails, and policy evaluation). See [ADR 019](adr/019-signal-adapter-architecture.md). + +### Alertmanager Webhook + +```http +POST /v1/signals/alertmanager +Authorization: Bearer +Content-Type: application/json +``` + +Accepts an [Alertmanager v4 webhook payload](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config). Each alert in the `alerts[]` array is processed independently. + +**Request:** + +```json +{ + "version": "4", + "status": "firing", + "alerts": [ + { + "status": "firing", + "labels": { + "victim_ip": "203.0.113.10", + "vector": "udp_flood", + "severity": "critical", + "alertname": "DDoS_Alert" + }, + "annotations": { + "bps": "500000000", + "pps": "1000000" + }, + "startsAt": "2026-03-19T10:30:00Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "http://prometheus:9090/graph", + "fingerprint": "abc123def456" + } + ], + "groupLabels": { "alertname": "DDoS_Alert" }, + "commonLabels": {}, + "commonAnnotations": {}, + "externalURL": "http://alertmanager.example.com" +} +``` + +**Label Mapping:** + +| AttackEventInput field | Alertmanager source | Fallback | +|---|---|---| +| `vector` | `labels.vector` | `labels.alertname` | +| `victim_ip` | `labels.victim_ip` | `labels.instance` (port stripped) | +| `bps` | `annotations.bps` (parsed as i64) | None | +| `pps` | `annotations.pps` (parsed as i64) | None | +| `confidence` | `labels.severity` → `critical`=0.9, `warning`=0.7, `info`=0.5 | 0.5 | +| `action` | `alerts[].status` ("resolved" → "unban", else "ban") | "ban" | +| `event_id` (dedup) | `alerts[].fingerprint` | None | +| `source` | hardcoded `"alertmanager"` | — | + +**Response (200):** + +```json +{ + "processed": 1, + "failed": 0, + "results": [ + { + "index": 0, + "status": "accepted", + "event_id": "550e8400-e29b-41d4-a716-446655440000", + "mitigation_id": "660e8400-e29b-41d4-a716-446655440001" + } + ] +} +``` + +**Per-alert status values:** + +| Status | Description | +|--------|-------------| +| `accepted` | Event created, mitigation may or may not be created | +| `extended` | Existing mitigation TTL extended | +| `duplicate` | Fingerprint already seen (dedup) | +| `withdrawn` | Resolved alert triggered mitigation withdrawal | +| `withdrawn_noop` | Resolved alert with no matching active mitigation | +| `error` | Processing failed for this alert (see `error` field) | + +**Error Responses:** + +| Status | Reason | +|--------|--------| +| 400 | Malformed payload (invalid JSON, wrong version, empty alerts) | +| 401 | Authentication required | + +> **Note:** Alertmanager will not retry 4xx errors, so malformed payloads return 400 to prevent infinite retry loops. + +**Alertmanager Configuration Snippet:** + +To point Alertmanager at prefixd, add a webhook receiver to your `alertmanager.yml`: + +```yaml +receivers: + - name: 'prefixd' + webhook_configs: + - url: 'http://prefixd.example.com/v1/signals/alertmanager' + http_config: + authorization: + type: Bearer + credentials: '' + send_resolved: true +``` + +### FastNetMon Webhook + +```http +POST /v1/signals/fastnetmon +Authorization: Bearer +Content-Type: application/json +``` + +Accepts FastNetMon's native JSON notify payload. Extracts attack vector from traffic breakdown, maps the `action` field to confidence via configurable mapping, and feeds the event into the standard ingestion pipeline (including correlation, guardrails, and policy evaluation). + +**Request:** + +```json +{ + "action": "ban", + "ip": "203.0.113.10", + "alert_scope": "host", + "attack_details": { + "attack_uuid": "550e8400-e29b-41d4-a716-446655440000", + "attack_severity": "high", + "attack_detection_source": "automatic", + "incoming_udp_pps": 500000, + "incoming_udp_traffic_bits": 4000000000, + "incoming_tcp_pps": 100, + "incoming_tcp_traffic_bits": 800000, + "incoming_syn_tcp_pps": 0, + "incoming_icmp_pps": 0, + "total_incoming_pps": 500100, + "total_incoming_traffic_bits": 4000800000, + "total_incoming_flows": 12000 + } +} +``` + +**Fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `action` | string | yes | `"ban"`, `"unban"`, `"partial_block"`, or `"alert"` | +| `ip` | string | yes | Victim IPv4 address under attack | +| `alert_scope` | string | no | Scope: `"host"` or `"total"` | +| `attack_details` | object | no | Traffic metrics and classification (see below) | + +**Attack Details Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `attack_uuid` | string | Unique attack ID (used as `external_event_id` for dedup) | +| `attack_severity` | string | Severity: `"low"`, `"middle"`, `"high"` | +| `attack_detection_source` | string | How detected: `"automatic"`, `"manual"` | +| `incoming_udp_pps` | integer | UDP packets per second | +| `incoming_udp_traffic_bits` | integer | UDP bits per second | +| `incoming_tcp_pps` | integer | TCP packets per second | +| `incoming_syn_tcp_pps` | integer | SYN TCP packets per second | +| `incoming_icmp_pps` | integer | ICMP packets per second | +| `total_incoming_pps` | integer | Total incoming packets per second | +| `total_incoming_traffic_bits` | integer | Total incoming bits per second | +| `total_incoming_flows` | integer | Total incoming flow count | + +**Confidence Mapping:** + +The `action` field maps to a confidence score (configurable in correlation config): + +| Action | Default Confidence | +|--------|--------------------| +| `ban` | 0.9 | +| `partial_block` | 0.7 | +| `alert` | 0.5 | +| Other | 0.5 | + +Override per-source confidence in `prefixd.yaml`: + +```yaml +correlation: + sources: + fastnetmon: + weight: 1.0 + type: detector + confidence_mapping: + ban: 0.95 + partial_block: 0.8 + alert: 0.4 +``` + +**Vector Classification:** + +The attack vector is automatically classified from the traffic breakdown in `attack_details`: + +- **UDP dominant** → `udp_flood` +- **SYN TCP dominant** (>60% of TCP PPS) → `syn_flood` +- **ICMP dominant** → `icmp_flood` +- **Other TCP** → `ack_flood` +- **No details** → `unknown` + +**Response (202 Accepted):** + +```json +{ + "event_id": "550e8400-e29b-41d4-a716-446655440000", + "external_event_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "accepted", + "mitigation_id": "7f72a903-63d1-4a4a-a5db-0517e0a7df1d" +} +``` + +The response uses the same `EventResponse` shape as `POST /v1/events` for compatibility with existing scripts. + +**Error Responses:** + +| Status | Reason | +|--------|--------| +| 400 | Malformed payload (invalid JSON, missing `ip` or `action`, invalid IP) | +| 401 | Authentication required | +| 422 | Guardrail rejection (safelist, quotas, prefix length) | + +**FastNetMon Configuration Snippet:** + +To configure FastNetMon Community to use prefixd, set the notify script in `/etc/fastnetmon.conf`: + +``` +notify_script_path = /opt/prefixd/scripts/prefixd-fastnetmon.sh +``` + +Or configure FastNetMon Advanced to use the webhook endpoint directly: + +``` +notify_script_format = json +notify_script_path = /usr/bin/curl -s -X POST http://prefixd.example.com/v1/signals/fastnetmon -H 'Content-Type: application/json' -H 'Authorization: Bearer ' -d @- +``` + +See `docs/detectors/fastnetmon.md` for a complete integration guide. + +--- + ## Safelist ### List Safelist diff --git a/docs/configuration.md b/docs/configuration.md index cc0c684..e747ea4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -262,6 +262,98 @@ safelist: - "192.168.0.0/16" # RFC1918 ``` +### Correlation + +The multi-signal correlation engine groups related attack events from multiple detection sources and uses corroboration to make high-confidence mitigation decisions. + +When `enabled` is false (the default), the correlation engine is bypassed and events follow the direct path to policy evaluation — identical to pre-correlation behavior. + +```yaml +correlation: + # Enable the correlation engine + enabled: true + + # Time window (seconds) for grouping signals by (victim_ip, vector). + # Events arriving within this window are added to the same signal group. + window_seconds: 300 + + # Global minimum number of distinct sources required before a signal group + # can trigger a mitigation. Set to 1 for backward-compatible single-source behavior. + min_sources: 1 + + # Global minimum derived confidence threshold (0.0-1.0). + # A signal group must reach this threshold (in addition to min_sources) before triggering. + confidence_threshold: 0.5 + + # Default weight for sources not listed below + default_weight: 1.0 + + # Per-source configuration: weight and type for known detection sources. + sources: + fastnetmon: + weight: 1.0 + type: detector + alertmanager: + weight: 0.8 + type: telemetry + dashboard: + weight: 1.0 + type: manual +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | boolean | `false` | Whether the correlation engine is active | +| `window_seconds` | integer | `300` | Time window for grouping signals (seconds) | +| `min_sources` | integer | `1` | Minimum distinct sources to trigger mitigation | +| `confidence_threshold` | float | `0.5` | Minimum derived confidence to trigger | +| `default_weight` | float | `1.0` | Weight for unknown/unconfigured sources | +| `sources` | map | `{}` | Per-source weight and type configuration | + +**Source Configuration:** + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `weight` | float | `1.0` | Weight in derived confidence computation (higher = more influence) | +| `type` | string | `""` | Descriptive type (`detector`, `telemetry`, `manual`) | + +**Derived confidence** is computed as a weighted average: + +``` +derived_confidence = sum(event_confidence_i × source_weight_i) / sum(source_weight_i) +``` + +Events with null or missing confidence are treated as 0.0. + +#### Per-Playbook Correlation Overrides + +Playbooks can override global `min_sources` and `confidence_threshold` for specific attack vectors. Add a `correlation` section to any playbook in `playbooks.yaml`: + +```yaml +playbooks: + - name: udp_flood_corroborated + match: + vector: udp_flood + correlation: + min_sources: 2 # Require corroboration for UDP floods + confidence_threshold: 0.7 + steps: + - action: police + rate_bps: 5000000 + ttl_seconds: 120 +``` + +When a playbook has no `correlation` override, the global defaults from `prefixd.yaml` are used. + +| Override Field | Type | Description | +|----------------|------|-------------| +| `min_sources` | integer | Override global min_sources for this playbook | +| `confidence_threshold` | float | Override global confidence_threshold for this playbook | + +#### Hot Reload + +Correlation config changes take effect on `POST /v1/config/reload` without restart (same as inventory and playbooks). + ### Shutdown ```yaml diff --git a/docs/deployment.md b/docs/deployment.md index 1c2260d..ae6153c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -65,6 +65,8 @@ open http://localhost > **Note:** The dashboard and API are not exposed directly. All HTTP and WebSocket traffic goes through nginx on port 80. +> **Config editing in Docker:** The default `docker-compose.yml` mounts `./configs:/etc/prefixd` (writable) so the dashboard config editors (playbooks, alerting, correlation) work out of the box. Writes use atomic temp-file + fsync + rename with `.bak` backups, and all PUT endpoints are admin-only with validation. For hardened deployments, add `:ro` to the mount and edit configs on the host, then reload via `POST /v1/config/reload`. + --- ## Authentication Setup @@ -147,6 +149,89 @@ bun run dev --- +## Signal Adapters (Detector Integration) + +prefixd accepts events three ways: + +1. **Generic API** (`POST /v1/events`) — any detector that can POST JSON +2. **Alertmanager webhook** (`POST /v1/signals/alertmanager`) — native Alertmanager v4 payload +3. **FastNetMon webhook** (`POST /v1/signals/fastnetmon`) — native FastNetMon JSON payload + +The signal adapters translate detector-native payloads into prefixd events and feed them through the full pipeline (correlation → policy → guardrails → announce). + +### Alertmanager Setup + +Add a webhook receiver to your Alertmanager config: + +```yaml +# alertmanager.yml +receivers: + - name: prefixd + webhook_configs: + - url: "http://prefixd.internal/v1/signals/alertmanager" + send_resolved: true # Enables auto-withdraw on resolve + +route: + routes: + - match: + severity: critical + receiver: prefixd +``` + +The adapter maps Alertmanager fields to prefixd events: + +| Alertmanager Field | Maps To | Notes | +|--------------------|---------|-------| +| `labels.instance` or `annotations.victim_ip` | `victim_ip` | Required — alert is skipped without it | +| `labels.vector` or `annotations.vector` | `vector` | Falls back to `unknown` | +| `labels.severity` | `confidence` | critical=0.9, warning=0.7, info=0.5 | +| `annotations.bps`, `annotations.pps` | Traffic metrics | Optional | +| `fingerprint` | Dedup key | Prevents duplicate events | + +Resolved alerts (status=`resolved`) automatically withdraw the corresponding mitigation. + +### FastNetMon Setup + +Point FastNetMon's webhook notify URL at prefixd: + +```bash +# FastNetMon Advanced +notify_script_path = /usr/bin/curl +notify_script_args = -X POST -H "Content-Type: application/json" \ + -d @- http://prefixd.internal/v1/signals/fastnetmon +``` + +Or use the notify script from `scripts/prefixd-fastnetmon.sh` which provides additional features (retry, logging, unban support). + +The adapter classifies the attack vector from the traffic breakdown (UDP/SYN/ICMP/TCP) and maps `action` to confidence: + +| FastNetMon Action | Confidence | +|-------------------|------------| +| `ban` | 0.9 | +| `partial_block` | 0.7 | +| `alert` | 0.5 | +| `unban` | Withdraws mitigation | + +### Multi-Signal Correlation + +When [correlation](configuration.md#correlation) is enabled, events from multiple adapters targeting the same (victim_ip, vector) within a time window are grouped into a signal group. Set `min_sources: 2` to require corroboration before triggering mitigation: + +```yaml +# configs/correlation.yaml +enabled: true +min_sources: 2 +window_seconds: 300 +sources: + fastnetmon: + weight: 1.0 + alertmanager: + weight: 0.8 +``` + +See [ADR 018](adr/018-multi-signal-correlation-engine.md) and [ADR 019](adr/019-signal-adapter-architecture.md) for design rationale. + +--- + ## GoBGP v4.x Setup prefixd requires GoBGP v4.0.0 or later. @@ -355,6 +440,7 @@ SELECT * FROM schema_migrations ORDER BY version; -- 4 | schema_migrations | 2026-02-20 10:00:00 -- 5 | acknowledge | 2026-03-18 14:37:00 -- 6 | notification_preferences | 2026-03-18 14:37:00 +-- 7 | signal_groups | 2026-03-19 18:00:00 ``` ### Check Migration Status @@ -641,6 +727,10 @@ scrape_configs: | `prefixd_http_request_duration_seconds` | Request latency histogram | | `prefixd_db_pool_connections` | DB pool stats (active, idle, total) | | `prefixd_db_row_parse_errors_total` | Corrupted row parse errors | +| `prefixd_signal_groups_total` | Signal groups created (correlation) | +| `prefixd_corroboration_met_total` | Groups that met corroboration threshold | +| `prefixd_corroboration_timeout_total` | Groups that expired without corroboration | +| `prefixd_correlation_confidence` | Derived confidence distribution | ### Alerting @@ -728,6 +818,7 @@ curl -v http://localhost/v1/health 2>&1 | grep x-request-id - [ ] Playbooks match security policy - [ ] Quotas set appropriately - [ ] Safelist populated with infrastructure IPs +- [ ] Correlation config tuned (if enabled): `min_sources`, `confidence_threshold`, source weights ### Testing @@ -736,6 +827,8 @@ curl -v http://localhost/v1/health 2>&1 | grep x-request-id - [ ] Test mitigation withdrawal - [ ] Verify TTL expiry works - [ ] Test dashboard login +- [ ] Test signal adapters (if using Alertmanager/FastNetMon) +- [ ] Verify corroboration behavior with `min_sources` > 1 (if enabled) --- diff --git a/docs/upgrading.md b/docs/upgrading.md index ae1e9a2..a922295 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -122,14 +122,121 @@ prefixdctl migrations # 4 schema_migrations 2026-02-20 10:00:00 # 5 acknowledge 2026-03-18 14:37:00 # 6 notification_preferences 2026-03-18 14:37:00 +# 7 signal_groups 2026-03-19 18:00:00 # -# 6 migration(s) applied +# 7 migration(s) applied ``` --- ## Version-Specific Notes +### v0.13.0 -> v0.14.0 (Unreleased) + +#### New: Multi-Signal Correlation Engine + +This release adds the correlation engine, signal adapters, and correlation dashboard. **No breaking changes** — correlation is opt-in. + +##### New config file: `correlation.yaml` + +Create `configs/correlation.yaml` (or add a `correlation:` section to `prefixd.yaml`). If the file is absent, correlation is disabled and behavior is identical to v0.13.0. + +```yaml +# configs/correlation.yaml +enabled: true +window_seconds: 300 +min_sources: 1 # Set to 2+ to require corroboration +confidence_threshold: 0.5 +default_weight: 1.0 +sources: + fastnetmon: + weight: 1.0 + type: detector + alertmanager: + weight: 0.8 + type: telemetry +``` + +With `min_sources: 1`, events flow through the correlation engine but mitigate immediately on the first signal — identical to pre-correlation behavior. Increase to 2+ to require corroboration from multiple detectors. + +See [configuration.md#correlation](configuration.md#correlation) for full reference. + +##### New database migration (007) + +Migration 007 (`signal_groups`) runs automatically on startup. It adds: + +- `signal_groups` table (group_id, victim_ip, vector, window, confidence, status) +- `signal_group_events` junction table +- `mitigations.signal_group_id` nullable FK column +- Two indexes for performance (victim/vector lookup, expiry sweep) + +This is an additive migration — it only creates new tables and adds a nullable column. **Safe to roll back** without a database restore (the new tables/column will be ignored by v0.13.0). + +##### New API endpoints + +| Endpoint | Auth | Description | +|----------|------|-------------| +| `GET /v1/signal-groups` | Yes | List signal groups (cursor pagination, status/vector/date filters) | +| `GET /v1/signal-groups/{id}` | Yes | Signal group detail with contributing events | +| `POST /v1/signals/alertmanager` | Yes | Alertmanager v4 webhook adapter | +| `POST /v1/signals/fastnetmon` | Yes | FastNetMon native JSON webhook adapter | +| `GET /v1/config/correlation` | Yes | Correlation config (secrets redacted) | +| `PUT /v1/config/correlation` | Admin | Update correlation config (validates, writes YAML, hot-reloads) | + +##### New API response fields + +- `GET /v1/mitigations` and `GET /v1/mitigations/{id}` responses now include an optional `correlation` field on correlated mitigations. Contains `signal_group_id`, `derived_confidence`, `source_count`, `corroboration_met`, `contributing_sources`, and `explanation`. This field is `null` for mitigations created without correlation. **Backward-compatible** — clients that don't read this field are unaffected. + +##### Signal adapter integration + +If you use **Alertmanager**, point a webhook receiver at `POST /v1/signals/alertmanager`. The adapter maps Alertmanager labels/annotations to attack event fields: + +| Alertmanager Field | Maps To | +|--------------------|---------| +| `labels.instance` or `annotations.victim_ip` | `victim_ip` | +| `labels.vector` or `annotations.vector` | `vector` | +| `labels.severity` (critical/warning/info) | `confidence` (0.9/0.7/0.5) | +| `annotations.bps`, `annotations.pps` | Traffic metrics | +| `fingerprint` | Dedup key (idempotent) | + +If you use **FastNetMon**, point the webhook notify URL at `POST /v1/signals/fastnetmon`. The adapter classifies vector from the traffic breakdown and maps `action` to confidence (ban=0.9, partial_block=0.7, alert=0.5). + +Both adapters feed events through the full pipeline (correlation → policy → guardrails → announce). + +##### New Prometheus metrics + +| Metric | Type | Description | +|--------|------|-------------| +| `prefixd_signal_groups_total` | Counter | Total signal groups created | +| `prefixd_signal_group_sources` | Histogram | Source count distribution per group | +| `prefixd_correlation_confidence` | Histogram | Derived confidence distribution | +| `prefixd_corroboration_met_total` | Counter | Groups that met corroboration threshold | +| `prefixd_corroboration_timeout_total` | Counter | Groups that expired without corroboration | + +##### Dashboard: Correlation page + +The dashboard adds a `/correlation` page with three tabs: + +- **Signals** — Recent events with source, confidence, and group assignment +- **Groups** — Signal groups with status, source count, confidence, corroboration +- **Config** — Visual correlation configuration editor + +Plus a signal group detail page at `/correlation/groups/[id]` and a correlation context section on the mitigation detail page. + +##### Docker: Config volume now writable by default + +The default `docker-compose.yml` now mounts `./configs:/etc/prefixd` (writable) so the dashboard config editors work out of the box. Previously it was `:ro`. If you have a customized `docker-compose.yml` with `:ro`, the `PUT /v1/config/correlation` endpoint (and playbooks/alerting PUT) will return 500. Either remove `:ro` or edit configs on the host and use `POST /v1/config/reload`. + +##### Upgrade steps + +1. Back up the database (as always) +2. Add `configs/correlation.yaml` if you want correlation (optional — omit to keep existing behavior) +3. Rebuild and restart: `docker compose build && docker compose up -d` +4. Migration 007 runs automatically +5. Verify: `curl http://localhost/v1/config/correlation` should return config (or defaults if no file) +6. If using Alertmanager/FastNetMon, point webhook URLs at the new adapter endpoints +7. Tune `min_sources` and `confidence_threshold` to taste + ### v0.11.0 -> v0.12.0 #### Breaking: Offset pagination removed diff --git a/frontend/__tests__/correlation.test.tsx b/frontend/__tests__/correlation.test.tsx new file mode 100644 index 0000000..e824b47 --- /dev/null +++ b/frontend/__tests__/correlation.test.tsx @@ -0,0 +1,418 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, within } from "@testing-library/react" + +// ─── Mocks ────────────────────────────────────────────── + +// Mock next/navigation +const mockPush = vi.fn() +const mockReplace = vi.fn() +const mockSearchParams = new URLSearchParams() +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush, replace: mockReplace }), + usePathname: () => "/correlation", + useSearchParams: () => mockSearchParams, +})) + +// Mock next/link +vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})) + +// Mock sonner +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +// Mock SWR config +vi.mock("swr", async () => { + const actual = await vi.importActual("swr") + return { ...actual } +}) + +// Mock use-api hooks +const mockUseSignalSources = vi.fn() +const mockUseSignalGroups = vi.fn() +const mockUseSignalGroupsPaginated = vi.fn() +const mockUseCorrelationConfig = vi.fn() +const mockUseConfigPlaybooks = vi.fn() +const mockUseOpenSignalGroupCount = vi.fn() + +vi.mock("@/hooks/use-api", () => ({ + useSignalSources: () => mockUseSignalSources(), + useSignalGroups: () => mockUseSignalGroups(), + useSignalGroupsPaginated: () => mockUseSignalGroupsPaginated(), + useCorrelationConfig: () => mockUseCorrelationConfig(), + useConfigPlaybooks: () => mockUseConfigPlaybooks(), + useOpenSignalGroupCount: () => mockUseOpenSignalGroupCount(), + useHealth: () => ({ data: { auth_mode: "none" }, isLoading: false }), + useStats: () => ({ data: { total_active: 3 } }), +})) + +// Mock use-permissions +const mockPermissions = vi.fn() +vi.mock("@/hooks/use-permissions", () => ({ + usePermissions: () => mockPermissions(), +})) + +// Mock use-auth +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ operator: { role: "admin" }, isLoading: false }), +})) + +// Mock use-keyboard-shortcuts +vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ + useKeyboardShortcuts: () => {}, +})) + +// ─── Imports ──────────────────────────────────────────── + +import { SignalsTab } from "@/components/dashboard/correlation/signals-tab" +import { GroupsTab } from "@/components/dashboard/correlation/groups-tab" +import { ConfigTab } from "@/components/dashboard/correlation/config-tab" + +// ─── Sample Data ──────────────────────────────────────── + +const sampleSources = [ + { name: "fastnetmon", type: "detector", weight: 1.0, last_seen: new Date().toISOString(), event_count: 42, healthy: true }, + { name: "alertmanager", type: "telemetry", weight: 0.8, last_seen: null, event_count: 0, healthy: false }, +] + +const sampleGroups = [ + { + group_id: "grp-1", + victim_ip: "203.0.113.10", + vector: "udp_flood", + created_at: new Date(Date.now() - 300000).toISOString(), + window_expires_at: new Date(Date.now() + 300000).toISOString(), + derived_confidence: 0.88, + source_count: 2, + status: "open" as const, + corroboration_met: true, + }, + { + group_id: "grp-2", + victim_ip: "198.51.100.25", + vector: "syn_flood", + created_at: new Date(Date.now() - 600000).toISOString(), + window_expires_at: new Date(Date.now() - 100000).toISOString(), + derived_confidence: 0.45, + source_count: 1, + status: "expired" as const, + corroboration_met: false, + }, +] + +const sampleConfig = { + enabled: true, + window_seconds: 300, + min_sources: 2, + confidence_threshold: 0.5, + default_weight: 1.0, + sources: { + fastnetmon: { weight: 1.0, type: "detector", confidence_mapping: {} }, + alertmanager: { weight: 0.8, type: "telemetry", confidence_mapping: {} }, + }, +} + +const samplePlaybooks = { + playbooks: [{ name: "udp_flood_default", match: { vector: "udp_flood" }, steps: [] }], + total_playbooks: 1, + loaded_at: new Date().toISOString(), +} + +// ─── Setup ────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks() + + mockPermissions.mockReturnValue({ + settled: true, + authDisabled: true, + isAdmin: true, + isOperator: true, + isViewer: true, + canWithdraw: true, + canManageSafelist: true, + canManageUsers: true, + canReloadConfig: true, + canEditPlaybooks: true, + canEditAlerting: true, + role: "admin", + }) +}) + +// ─── Tests ────────────────────────────────────────────── + +describe("SignalsTab", () => { + it("renders source status cards with health indicators", () => { + mockUseSignalSources.mockReturnValue({ data: sampleSources, error: null, isLoading: false }) + mockUseSignalGroups.mockReturnValue({ + data: { groups: sampleGroups, count: 2, next_cursor: null, has_more: false }, + error: null, + isLoading: false, + }) + + render() + + // Source cards visible (may appear in both cards and weight viz) + expect(screen.getAllByText("fastnetmon").length).toBeGreaterThan(0) + expect(screen.getAllByText("alertmanager").length).toBeGreaterThan(0) + + // Type badges + expect(screen.getByText("detector")).toBeInTheDocument() + expect(screen.getByText("telemetry")).toBeInTheDocument() + + // Weight display + expect(screen.getAllByText("1.0").length).toBeGreaterThan(0) + expect(screen.getAllByText("0.8").length).toBeGreaterThan(0) + }) + + it("renders loading skeletons while fetching", () => { + mockUseSignalSources.mockReturnValue({ data: undefined, error: null, isLoading: true }) + mockUseSignalGroups.mockReturnValue({ data: undefined, error: null, isLoading: true }) + + const { container } = render() + + // Skeleton elements are rendered + const skeletons = container.querySelectorAll("[data-slot='skeleton']") + expect(skeletons.length).toBeGreaterThan(0) + }) + + it("renders empty state when no sources configured", () => { + mockUseSignalSources.mockReturnValue({ data: [], error: null, isLoading: false }) + mockUseSignalGroups.mockReturnValue({ + data: { groups: [], count: 0, next_cursor: null, has_more: false }, + error: null, + isLoading: false, + }) + + render() + + expect(screen.getByText("No signal sources configured")).toBeInTheDocument() + }) + + it("renders error state on fetch failure", () => { + mockUseSignalSources.mockReturnValue({ data: undefined, error: new Error("fail"), isLoading: false }) + mockUseSignalGroups.mockReturnValue({ + data: { groups: [], count: 0, next_cursor: null, has_more: false }, + error: null, + isLoading: false, + }) + + render() + + expect(screen.getByText("Failed to load signal sources")).toBeInTheDocument() + }) + + it("renders recent signals table with group data", () => { + mockUseSignalSources.mockReturnValue({ data: sampleSources, error: null, isLoading: false }) + mockUseSignalGroups.mockReturnValue({ + data: { groups: sampleGroups, count: 2, next_cursor: null, has_more: false }, + error: null, + isLoading: false, + }) + + render() + + // Table headers + expect(screen.getByText("Victim IP")).toBeInTheDocument() + expect(screen.getByText("Vector")).toBeInTheDocument() + + // Group data + expect(screen.getByText("203.0.113.10")).toBeInTheDocument() + expect(screen.getByText("198.51.100.25")).toBeInTheDocument() + expect(screen.getByText("udp flood")).toBeInTheDocument() + expect(screen.getByText("syn flood")).toBeInTheDocument() + }) +}) + +describe("GroupsTab", () => { + it("renders filterable group list", () => { + mockUseSignalGroupsPaginated.mockReturnValue({ + data: [{ groups: sampleGroups, count: 2, next_cursor: null, has_more: false }], + error: null, + isLoading: false, + isValidating: false, + size: 1, + setSize: vi.fn(), + }) + + render() + + // Table with data + expect(screen.getByText("203.0.113.10")).toBeInTheDocument() + expect(screen.getByText("198.51.100.25")).toBeInTheDocument() + expect(screen.getByText("88%")).toBeInTheDocument() + expect(screen.getByText("45%")).toBeInTheDocument() + }) + + it("renders empty state with clear-filters option", () => { + mockUseSignalGroupsPaginated.mockReturnValue({ + data: [{ groups: [], count: 0, next_cursor: null, has_more: false }], + error: null, + isLoading: false, + isValidating: false, + size: 1, + setSize: vi.fn(), + }) + + render() + + expect(screen.getByText("No signal groups found")).toBeInTheDocument() + }) + + it("renders loading skeletons", () => { + mockUseSignalGroupsPaginated.mockReturnValue({ + data: undefined, + error: null, + isLoading: true, + isValidating: false, + size: 1, + setSize: vi.fn(), + }) + + const { container } = render() + + const skeletons = container.querySelectorAll("[data-slot='skeleton']") + expect(skeletons.length).toBeGreaterThan(0) + }) + + it("renders error state", () => { + mockUseSignalGroupsPaginated.mockReturnValue({ + data: undefined, + error: new Error("fail"), + isLoading: false, + isValidating: false, + size: 1, + setSize: vi.fn(), + }) + + render() + + expect(screen.getByText("Failed to load signal groups")).toBeInTheDocument() + }) + + it("shows Load More button when has_more is true", () => { + mockUseSignalGroupsPaginated.mockReturnValue({ + data: [{ groups: sampleGroups, count: 2, next_cursor: "abc", has_more: true }], + error: null, + isLoading: false, + isValidating: false, + size: 1, + setSize: vi.fn(), + }) + + render() + + expect(screen.getByText("Load More")).toBeInTheDocument() + }) +}) + +describe("ConfigTab", () => { + it("renders correlation settings form (admin)", () => { + mockUseCorrelationConfig.mockReturnValue({ + data: sampleConfig, + error: null, + isLoading: false, + mutate: vi.fn(), + }) + mockUseConfigPlaybooks.mockReturnValue({ data: samplePlaybooks }) + + render() + + // Settings section + expect(screen.getByText("Correlation Settings")).toBeInTheDocument() + expect(screen.getByText("Enabled")).toBeInTheDocument() + + // Form fields + expect(screen.getByLabelText("Window (seconds)")).toBeInTheDocument() + expect(screen.getByLabelText("Min Sources")).toBeInTheDocument() + expect(screen.getByLabelText("Confidence Threshold")).toBeInTheDocument() + + // Signal sources section + expect(screen.getByText("Signal Sources")).toBeInTheDocument() + expect(screen.getByText("Add Source")).toBeInTheDocument() + }) + + it("shows read-only message for non-admin", () => { + mockPermissions.mockReturnValue({ + settled: true, + authDisabled: false, + isAdmin: false, + isOperator: true, + isViewer: true, + canWithdraw: true, + canManageSafelist: false, + canManageUsers: false, + canReloadConfig: false, + canEditPlaybooks: false, + canEditAlerting: false, + role: "operator", + }) + + mockUseCorrelationConfig.mockReturnValue({ + data: sampleConfig, + error: null, + isLoading: false, + mutate: vi.fn(), + }) + mockUseConfigPlaybooks.mockReturnValue({ data: samplePlaybooks }) + + render() + + expect(screen.getByText("Admin access required to edit settings")).toBeInTheDocument() + // Add Source button should not be present for non-admin + expect(screen.queryByText("Add Source")).not.toBeInTheDocument() + }) + + it("renders per-playbook overrides with link to Playbooks tab", () => { + mockUseCorrelationConfig.mockReturnValue({ + data: sampleConfig, + error: null, + isLoading: false, + mutate: vi.fn(), + }) + mockUseConfigPlaybooks.mockReturnValue({ data: samplePlaybooks }) + + render() + + expect(screen.getByText("Per-Playbook Overrides")).toBeInTheDocument() + expect(screen.getByText("udp_flood_default")).toBeInTheDocument() + expect(screen.getByText("Edit in Playbooks")).toBeInTheDocument() + }) + + it("renders source CRUD cards for admin", () => { + mockUseCorrelationConfig.mockReturnValue({ + data: sampleConfig, + error: null, + isLoading: false, + mutate: vi.fn(), + }) + mockUseConfigPlaybooks.mockReturnValue({ data: samplePlaybooks }) + + render() + + // Source cards + expect(screen.getByText("fastnetmon")).toBeInTheDocument() + expect(screen.getByText("alertmanager")).toBeInTheDocument() + + // Edit and Remove buttons visible for admin + expect(screen.getAllByText("Edit").length).toBe(2) + expect(screen.getAllByText("Remove").length).toBe(2) + }) + + it("renders loading state", () => { + mockUseCorrelationConfig.mockReturnValue({ + data: undefined, + error: null, + isLoading: true, + mutate: vi.fn(), + }) + mockUseConfigPlaybooks.mockReturnValue({ data: null }) + + const { container } = render() + + const skeletons = container.querySelectorAll("[data-slot='skeleton']") + expect(skeletons.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/__tests__/mitigation-detail-correlation.test.tsx b/frontend/__tests__/mitigation-detail-correlation.test.tsx new file mode 100644 index 0000000..14c4c3b --- /dev/null +++ b/frontend/__tests__/mitigation-detail-correlation.test.tsx @@ -0,0 +1,382 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import { Suspense } from "react" + +// ─── Mocks ────────────────────────────────────────────── + +const mockPush = vi.fn() +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush, back: vi.fn() }), + usePathname: () => "/mitigations/mit-001", + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})) + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +// Mock use-api hooks +const mockUseMitigation = vi.fn() +const mockUseConfigInventory = vi.fn() +const mockUseSignalGroupDetail = vi.fn() + +vi.mock("@/hooks/use-api", () => ({ + useMitigation: (...args: unknown[]) => mockUseMitigation(...args), + useConfigInventory: () => mockUseConfigInventory(), + useSignalGroupDetail: (...args: unknown[]) => mockUseSignalGroupDetail(...args), +})) + +// Mock use-permissions +vi.mock("@/hooks/use-permissions", () => ({ + usePermissions: () => ({ + settled: true, + authDisabled: true, + isAdmin: true, + isOperator: true, + canWithdraw: true, + canAcknowledge: true, + }), +})) + +// Mock DashboardLayout +vi.mock("@/components/dashboard/dashboard-layout", () => ({ + DashboardLayout: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +// Mock StatusBadge and ActionBadge +vi.mock("@/components/dashboard/status-badge", () => ({ + StatusBadge: ({ status }: { status: string }) => {status}, +})) + +vi.mock("@/components/dashboard/action-badge", () => ({ + ActionBadge: () => action, +})) + +// Mock FlowSpecPreview +vi.mock("@/components/dashboard/flowspec-preview", () => ({ + FlowSpecPreview: () =>
, + formatFlowSpecRule: () => "flowspec-rule", +})) + +// Mock IncidentReportDialog +vi.mock("@/components/dashboard/incident-report-dialog", () => ({ + IncidentReportDialog: () => null, +})) + +// Mock API functions +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api") + return { + ...actual, + withdrawMitigation: vi.fn(), + getIncidentReport: vi.fn(), + } +}) + +// ─── Imports ──────────────────────────────────────────── + +import MitigationDetailPage from "@/app/(dashboard)/mitigations/[id]/page" + +// ─── Sample Data ──────────────────────────────────────── + +const baseMitigation = { + mitigation_id: "mit-001-aaaa-bbbb-cccc", + scope_hash: "abc123", + status: "active" as const, + customer_id: "cust_acme", + service_id: "svc_dns", + pop: "iad1", + victim_ip: "203.0.113.10", + vector: "udp_flood", + action_type: "police" as const, + rate_bps: 5000000, + dst_prefix: "203.0.113.10/32", + protocol: 17, + dst_ports: [53], + created_at: new Date(Date.now() - 120000).toISOString(), + updated_at: new Date(Date.now() - 120000).toISOString(), + expires_at: new Date(Date.now() + 180000).toISOString(), + withdrawn_at: null, + triggering_event_id: "evt-001", + last_event_id: "evt-001", + reason: "UDP flood detected", + acknowledged_at: null, + acknowledged_by: null, +} + +const correlatedMitigation = { + ...baseMitigation, + correlation: { + signal_group_id: "sg-001-aaaa-bbbb-cccc-dddd", + derived_confidence: 0.88, + source_count: 2, + corroboration_met: true, + contributing_sources: ["fastnetmon", "alertmanager"], + explanation: + 'Corroboration achieved: 2 of 2 required sources confirmed UDP flood on 203.0.113.10. Derived confidence 88% (threshold 50%). Sources: fastnetmon (95% × 1.0), alertmanager (80% × 0.8).', + }, +} + +const nonCorrelatedMitigation = { + ...baseMitigation, + correlation: null, +} + +const signalGroupDetailData = { + group_id: "sg-001-aaaa-bbbb-cccc-dddd", + victim_ip: "203.0.113.10", + vector: "udp_flood", + created_at: new Date(Date.now() - 300000).toISOString(), + window_expires_at: new Date(Date.now() + 300000).toISOString(), + derived_confidence: 0.88, + source_count: 2, + status: "resolved" as const, + corroboration_met: true, + mitigation_id: "mit-001-aaaa-bbbb-cccc", + events: [ + { + group_id: "sg-001-aaaa-bbbb-cccc-dddd", + event_id: "evt-001", + source: "fastnetmon", + confidence: 0.95, + source_weight: 1.0, + ingested_at: new Date(Date.now() - 300000).toISOString(), + victim_ip: "203.0.113.10", + vector: "udp_flood", + }, + { + group_id: "sg-001-aaaa-bbbb-cccc-dddd", + event_id: "evt-002", + source: "alertmanager", + confidence: 0.8, + source_weight: 0.8, + ingested_at: new Date(Date.now() - 240000).toISOString(), + victim_ip: "203.0.113.10", + vector: "udp_flood", + }, + ], +} + +// ─── Helper ───────────────────────────────────────────── + +async function renderPage() { + let result: ReturnType + await act(async () => { + result = render( + Loading suspense...
}> + + + ) + }) + return result! +} + +// ─── Tests ────────────────────────────────────────────── + +describe("Mitigation Detail – Correlation Section", () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseConfigInventory.mockReturnValue({ data: null }) + }) + + it("shows Correlation card with all fields for a correlated mitigation", async () => { + mockUseMitigation.mockReturnValue({ + data: correlatedMitigation, + isLoading: false, + mutate: vi.fn(), + }) + mockUseSignalGroupDetail.mockReturnValue({ + data: signalGroupDetailData, + isLoading: false, + error: null, + }) + + await renderPage() + + expect(screen.getByTestId("correlation-card")).toBeTruthy() + + // Signal group link + const groupLink = screen.getByText("sg-001-aaaa-bbbb-cccc-dddd") + expect(groupLink.closest("a")).toBeTruthy() + expect(groupLink.closest("a")?.getAttribute("href")).toBe( + "/correlation/groups/sg-001-aaaa-bbbb-cccc-dddd" + ) + + // Derived confidence percentage + expect(screen.getByText("88%")).toBeTruthy() + + // Source count + expect(screen.getByText(/2 sources/)).toBeTruthy() + + // Contributing source names (badge + table) + expect(screen.getAllByText("fastnetmon").length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText("alertmanager").length).toBeGreaterThanOrEqual(1) + + // Corroboration badge + expect(screen.getByText("Corroborated")).toBeTruthy() + + // Why explanation text + expect(screen.getByText(/Corroboration achieved/)).toBeTruthy() + }) + + it("shows contributing sources table with confidence and weight from signal group detail", async () => { + mockUseMitigation.mockReturnValue({ + data: correlatedMitigation, + isLoading: false, + mutate: vi.fn(), + }) + mockUseSignalGroupDetail.mockReturnValue({ + data: signalGroupDetailData, + isLoading: false, + error: null, + }) + + await renderPage() + + // Table headers + expect(screen.getByText("Contributing Sources")).toBeTruthy() + expect(screen.getByText("Confidence")).toBeTruthy() + expect(screen.getByText("Weight")).toBeTruthy() + + // fastnetmon: 95% confidence, 1.0 weight + expect(screen.getByText("95%")).toBeTruthy() + expect(screen.getByText("1.0")).toBeTruthy() + + // alertmanager: 80% confidence, 0.8 weight + expect(screen.getByText("80%")).toBeTruthy() + expect(screen.getByText("0.8")).toBeTruthy() + }) + + it("shows signal group link that navigates to /correlation/groups/{id}", async () => { + mockUseMitigation.mockReturnValue({ + data: correlatedMitigation, + isLoading: false, + mutate: vi.fn(), + }) + mockUseSignalGroupDetail.mockReturnValue({ + data: signalGroupDetailData, + isLoading: false, + error: null, + }) + + await renderPage() + + const link = screen.getByText("sg-001-aaaa-bbbb-cccc-dddd") + const anchor = link.closest("a") + expect(anchor?.getAttribute("href")).toBe( + "/correlation/groups/sg-001-aaaa-bbbb-cccc-dddd" + ) + }) + + it("shows muted message for non-correlated mitigation", async () => { + mockUseMitigation.mockReturnValue({ + data: nonCorrelatedMitigation, + isLoading: false, + mutate: vi.fn(), + }) + mockUseSignalGroupDetail.mockReturnValue({ + data: null, + isLoading: false, + error: null, + }) + + await renderPage() + + expect(screen.getByTestId("no-correlation-card")).toBeTruthy() + expect(screen.getByText(/Single-source mitigation/)).toBeTruthy() + expect(screen.queryByTestId("correlation-card")).toBeNull() + }) + + it("hides correlation section when mitigation has no correlation field", async () => { + const mitigationWithoutCorrelation = { ...baseMitigation } + // No correlation field at all + mockUseMitigation.mockReturnValue({ + data: mitigationWithoutCorrelation, + isLoading: false, + mutate: vi.fn(), + }) + mockUseSignalGroupDetail.mockReturnValue({ + data: null, + isLoading: false, + error: null, + }) + + await renderPage() + + expect(screen.getByTestId("no-correlation-card")).toBeTruthy() + expect(screen.getByText(/Single-source mitigation/)).toBeTruthy() + }) + + it("renders pending corroboration badge when not met", async () => { + const pendingCorrelation = { + ...baseMitigation, + correlation: { + signal_group_id: "sg-002", + derived_confidence: 0.45, + source_count: 1, + corroboration_met: false, + contributing_sources: ["fastnetmon"], + explanation: "Awaiting additional sources for corroboration.", + }, + } + + mockUseMitigation.mockReturnValue({ + data: pendingCorrelation, + isLoading: false, + mutate: vi.fn(), + }) + mockUseSignalGroupDetail.mockReturnValue({ + data: null, + isLoading: false, + error: null, + }) + + await renderPage() + + expect(screen.getByTestId("correlation-card")).toBeTruthy() + expect(screen.getByText("Pending")).toBeTruthy() + expect(screen.queryByText("Corroborated")).toBeNull() + }) + + it("passes signal_group_id to useSignalGroupDetail hook", async () => { + mockUseMitigation.mockReturnValue({ + data: correlatedMitigation, + isLoading: false, + mutate: vi.fn(), + }) + mockUseSignalGroupDetail.mockReturnValue({ + data: signalGroupDetailData, + isLoading: false, + error: null, + }) + + await renderPage() + + expect(mockUseSignalGroupDetail).toHaveBeenCalledWith( + "sg-001-aaaa-bbbb-cccc-dddd" + ) + }) + + it("passes null to useSignalGroupDetail when no correlation", async () => { + mockUseMitigation.mockReturnValue({ + data: nonCorrelatedMitigation, + isLoading: false, + mutate: vi.fn(), + }) + mockUseSignalGroupDetail.mockReturnValue({ + data: null, + isLoading: false, + error: null, + }) + + await renderPage() + + expect(mockUseSignalGroupDetail).toHaveBeenCalledWith(null) + }) +}) diff --git a/frontend/__tests__/signal-group-detail.test.tsx b/frontend/__tests__/signal-group-detail.test.tsx new file mode 100644 index 0000000..d819d98 --- /dev/null +++ b/frontend/__tests__/signal-group-detail.test.tsx @@ -0,0 +1,328 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, waitFor, act } from "@testing-library/react" +import { Suspense } from "react" + +// ─── Mocks ────────────────────────────────────────────── + +const mockPush = vi.fn() +const mockReplace = vi.fn() +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush, replace: mockReplace, back: vi.fn() }), + usePathname: () => "/correlation/groups/grp-1", + useSearchParams: () => new URLSearchParams(), +})) + +vi.mock("next/link", () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})) + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +// Mock use-api hooks +const mockUseSignalGroupDetail = vi.fn() +const mockUseCorrelationConfig = vi.fn() + +vi.mock("@/hooks/use-api", () => ({ + useSignalGroupDetail: (...args: unknown[]) => mockUseSignalGroupDetail(...args), + useCorrelationConfig: () => mockUseCorrelationConfig(), +})) + + + +// Mock DashboardLayout to avoid needing all context providers +vi.mock("@/components/dashboard/dashboard-layout", () => ({ + DashboardLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +// ─── Imports ──────────────────────────────────────────── + +import SignalGroupDetailPage from "@/app/(dashboard)/correlation/groups/[id]/page" + +// ─── Sample Data ──────────────────────────────────────── + +const sampleEvents = [ + { + group_id: "grp-1", + event_id: "evt-001-aaaa-bbbb-cccc", + source: "fastnetmon", + confidence: 0.95, + source_weight: 1.0, + ingested_at: new Date(Date.now() - 300000).toISOString(), + victim_ip: "203.0.113.10", + vector: "udp_flood", + }, + { + group_id: "grp-1", + event_id: "evt-002-aaaa-bbbb-cccc", + source: "alertmanager", + confidence: 0.8, + source_weight: 0.8, + ingested_at: new Date(Date.now() - 240000).toISOString(), + victim_ip: "203.0.113.10", + vector: "udp_flood", + }, +] + +const resolvedGroup = { + group_id: "grp-1", + victim_ip: "203.0.113.10", + vector: "udp_flood", + created_at: new Date(Date.now() - 300000).toISOString(), + window_expires_at: new Date(Date.now() + 300000).toISOString(), + derived_confidence: 0.88, + source_count: 2, + status: "resolved" as const, + corroboration_met: true, + events: sampleEvents, + mitigation_id: "mit-001-aaaa-bbbb-cccc", +} + +const openGroup = { + group_id: "grp-2", + victim_ip: "198.51.100.25", + vector: "syn_flood", + created_at: new Date(Date.now() - 120000).toISOString(), + window_expires_at: new Date(Date.now() + 180000).toISOString(), + derived_confidence: 0.45, + source_count: 1, + status: "open" as const, + corroboration_met: false, + events: [sampleEvents[0]], + mitigation_id: null, +} + +const expiredGroup = { + group_id: "grp-3", + victim_ip: "192.0.2.100", + vector: "ntp_amplification", + created_at: new Date(Date.now() - 600000).toISOString(), + window_expires_at: new Date(Date.now() - 300000).toISOString(), + derived_confidence: 0.65, + source_count: 1, + status: "expired" as const, + corroboration_met: false, + events: [sampleEvents[0]], + mitigation_id: null, +} + +const sampleConfig = { + enabled: true, + window_seconds: 300, + min_sources: 2, + confidence_threshold: 0.5, + default_weight: 1.0, + sources: { + fastnetmon: { weight: 1.0, type: "detector", confidence_mapping: {} }, + alertmanager: { weight: 0.8, type: "telemetry", confidence_mapping: {} }, + }, +} + +// ─── Setup ────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks() + mockUseCorrelationConfig.mockReturnValue({ + data: sampleConfig, + error: null, + isLoading: false, + }) +}) + +// ─── Tests ────────────────────────────────────────────── + +// Helper: render page and wait for the async params promise to resolve +async function renderPage(id: string) { + let result: ReturnType + await act(async () => { + result = render( + Loading suspense...}> + + , + ) + }) + return result! +} + +describe("SignalGroupDetailPage", () => { + it("renders loading state", async () => { + mockUseSignalGroupDetail.mockReturnValue({ + data: undefined, + error: null, + isLoading: true, + }) + + const { container } = await renderPage("grp-1") + + // Should show a spinner + const spinner = container.querySelector(".animate-spin") + expect(spinner).toBeTruthy() + }) + + it("renders not-found state for unknown group ID", async () => { + mockUseSignalGroupDetail.mockReturnValue({ + data: undefined, + error: new Error("Not found"), + isLoading: false, + }) + + await renderPage("unknown-id") + + expect(screen.getByText("Signal Group Not Found")).toBeInTheDocument() + expect( + screen.getByText( + "The requested signal group ID does not exist or has been removed.", + ), + ).toBeInTheDocument() + expect(screen.getByText("Back to Correlation")).toBeInTheDocument() + }) + + it("renders resolved group with all sections", async () => { + mockUseSignalGroupDetail.mockReturnValue({ + data: resolvedGroup, + error: null, + isLoading: false, + }) + + await renderPage("grp-1") + + // Header: victim IP + expect(screen.getByText("203.0.113.10")).toBeInTheDocument() + + // Vector badge + expect(screen.getByText("udp flood")).toBeInTheDocument() + + // Status badge + expect(screen.getByText("Resolved")).toBeInTheDocument() + + // Source count + expect(screen.getByText("2 sources")).toBeInTheDocument() + + // Contributing events section + expect(screen.getByText("Contributing Events")).toBeInTheDocument() + expect(screen.getByText("2 events")).toBeInTheDocument() + + // Events in timeline with source badges (appear in both timeline and confidence table) + expect(screen.getAllByText("fastnetmon").length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText("alertmanager").length).toBeGreaterThanOrEqual(1) + + // Confidence breakdown section + expect(screen.getByText("Confidence Breakdown")).toBeInTheDocument() + expect(screen.getByText("Raw Confidence")).toBeInTheDocument() + expect(screen.getByText("Weighted Contribution")).toBeInTheDocument() + expect(screen.getByText("Derived Total")).toBeInTheDocument() + + // Corroboration section — should show "Corroborated" + expect(screen.getByText("Corroborated")).toBeInTheDocument() + + // Linked mitigation card + expect(screen.getByText("Linked Mitigation")).toBeInTheDocument() + const mitigationLink = screen.getByText("mit-001-…") + expect(mitigationLink.closest("a")).toHaveAttribute( + "href", + "/mitigations/mit-001-aaaa-bbbb-cccc", + ) + }) + + it("renders open group with pending corroboration", async () => { + mockUseSignalGroupDetail.mockReturnValue({ + data: openGroup, + error: null, + isLoading: false, + }) + + await renderPage("grp-2") + + // Header + expect(screen.getByText("198.51.100.25")).toBeInTheDocument() + expect(screen.getByText("syn flood")).toBeInTheDocument() + expect(screen.getByText("Open")).toBeInTheDocument() + + // Pending corroboration badge + expect(screen.getByText("Pending Corroboration 1/2")).toBeInTheDocument() + expect( + screen.getByText("1 more distinct source needed"), + ).toBeInTheDocument() + + // No mitigation - open status explanation + expect(screen.getByText("No mitigation created")).toBeInTheDocument() + expect( + screen.getByText(/Corroboration threshold has not been met yet/), + ).toBeInTheDocument() + }) + + it("renders expired group with appropriate explanation", async () => { + mockUseSignalGroupDetail.mockReturnValue({ + data: expiredGroup, + error: null, + isLoading: false, + }) + + await renderPage("grp-3") + + // Status badge + expect(screen.getByText("Expired")).toBeInTheDocument() + + // Pending corroboration + expect(screen.getByText("Pending Corroboration 1/2")).toBeInTheDocument() + + // No mitigation - expired explanation + expect(screen.getByText("No mitigation created")).toBeInTheDocument() + expect( + screen.getByText(/correlation window expired/), + ).toBeInTheDocument() + }) + + it("renders confidence breakdown with correct math", async () => { + mockUseSignalGroupDetail.mockReturnValue({ + data: resolvedGroup, + error: null, + isLoading: false, + }) + + await renderPage("grp-1") + + // The derived confidence of 88% appears in both the confidence table and summary sidebar + const allConfidence = screen.getAllByText("88%") + expect(allConfidence.length).toBeGreaterThanOrEqual(2) // table footer + summary + + // Weight column should show total weight + // fastnetmon 1.0 + alertmanager 0.8 = 1.8 + expect(screen.getByText("1.8")).toBeInTheDocument() + }) + + it("renders bidirectional navigation links", async () => { + mockUseSignalGroupDetail.mockReturnValue({ + data: resolvedGroup, + error: null, + isLoading: false, + }) + + await renderPage("grp-1") + + // Back link + expect(screen.getByText("Back to Correlation")).toBeInTheDocument() + + // Mitigation link + const mitigationLink = screen.getByText("mit-001-…").closest("a") + expect(mitigationLink).toHaveAttribute( + "href", + "/mitigations/mit-001-aaaa-bbbb-cccc", + ) + + // IP history link + const ipLink = screen.getByText("203.0.113.10").closest("a") + expect(ipLink).toHaveAttribute( + "href", + "/ip-history?ip=203.0.113.10", + ) + + // Event links + const eventLink = screen.getByText("evt-001-") + expect(eventLink.closest("a")).toHaveAttribute( + "href", + "/events?id=evt-001-aaaa-bbbb-cccc", + ) + }) +}) diff --git a/frontend/app/(dashboard)/correlation/groups/[id]/page.tsx b/frontend/app/(dashboard)/correlation/groups/[id]/page.tsx new file mode 100644 index 0000000..79d5210 --- /dev/null +++ b/frontend/app/(dashboard)/correlation/groups/[id]/page.tsx @@ -0,0 +1,485 @@ +"use client" + +import { use } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { DashboardLayout } from "@/components/dashboard/dashboard-layout" +import { useSignalGroupDetail, useCorrelationConfig } from "@/hooks/use-api" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + ArrowLeft, + Layers, + RefreshCw, + ShieldAlert, + Clock, + BarChart3, + CheckCircle2, + AlertTriangle, + Link2, + Info, +} from "lucide-react" + +// ── Helpers ────────────────────────────────────────────── + +function formatTimestamp(dateStr: string): string { + return ( + new Date(dateStr).toLocaleString("en-US", { + timeZone: "UTC", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + " UTC" + ) +} + +function statusBadge(status: string, corroborated: boolean) { + if (status === "resolved") { + return ( + + Resolved + + ) + } + if (status === "expired") { + return ( + + Expired + + ) + } + return ( + + {corroborated ? "Corroborated" : "Open"} + + ) +} + +const SOURCE_COLORS: Record = { + fastnetmon: "bg-blue-500", + alertmanager: "bg-orange-500", + dashboard: "bg-purple-500", +} + +function sourceColor(source: string): string { + return SOURCE_COLORS[source] ?? "bg-gray-500" +} + +// ── Page Component ─────────────────────────────────────── + +export default function SignalGroupDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const router = useRouter() + + const { data: group, isLoading, error } = useSignalGroupDetail(id) + const { data: correlationConfig } = useCorrelationConfig() + + // Loading state + if (isLoading) { + return ( + +
+ +
+
+ ) + } + + // 404 / error state + if (error || !group) { + return ( + +
+ +

Signal Group Not Found

+

+ The requested signal group ID does not exist or has been removed. +

+ +
+
+ ) + } + + // Sort events chronologically (earliest first) + const sortedEvents = [...group.events].sort( + (a, b) => + new Date(a.ingested_at).getTime() - new Date(b.ingested_at).getTime(), + ) + + // Confidence breakdown calculations + const totalWeight = sortedEvents.reduce((sum, e) => sum + e.source_weight, 0) + const confidenceRows = sortedEvents.map((e) => { + const rawConfidence = e.confidence ?? 0 + const weightedContribution = + totalWeight > 0 ? (rawConfidence * e.source_weight) / totalWeight : 0 + return { + source: e.source, + event_id: e.event_id, + rawConfidence, + weight: e.source_weight, + weightedContribution, + } + }) + + // Distinct source count for corroboration + const distinctSources = new Set(sortedEvents.map((e) => e.source)).size + const minSources = correlationConfig?.min_sources ?? 1 + + return ( + +
+ {/* ── Header ────────────────────────────────────────── */} +
+ + +
+
+
+ {statusBadge(group.status, group.corroboration_met)} + + {group.victim_ip} + +
+
+ + {group.vector.replace(/_/g, " ")} + + + {group.source_count} source{group.source_count !== 1 ? "s" : ""} + +
+
+ +
+
Created: {formatTimestamp(group.created_at)}
+
+ Window:{" "} + {group.status === "open" + ? `Expires ${formatTimestamp(group.window_expires_at)}` + : group.status === "expired" + ? `Expired ${formatTimestamp(group.window_expires_at)}` + : `Closed ${formatTimestamp(group.window_expires_at)}`} +
+
ID: {group.group_id}
+
+
+
+ +
+ {/* ── Main Column ─────────────────────────────────── */} +
+ {/* Contributing Events Timeline */} + + +
+ + + Contributing Events + + + {sortedEvents.length} event{sortedEvents.length !== 1 ? "s" : ""} + +
+
+ + {sortedEvents.length === 0 ? ( +

+ No contributing events recorded. +

+ ) : ( +
+ {sortedEvents.map((event, idx) => ( +
+
+
+
+ + {event.source} + + + Confidence:{" "} + {event.confidence != null + ? `${Math.round(event.confidence * 100)}%` + : "N/A"} + +
+

+ {formatTimestamp(event.ingested_at)} +

+

+ Event{" "} + + {event.event_id.slice(0, 8)} + + + weight: {event.source_weight.toFixed(1)} + +

+
+
+ ))} +
+ )} + + + + {/* Confidence Breakdown */} + + +
+ + + Confidence Breakdown + +
+
+ +
+ + + + + + + + + + + {confidenceRows.map((row) => ( + + + + + + + ))} + + + + + + + + +
Source + Raw Confidence + + Weight + + Weighted Contribution +
+
+
+ {row.source} +
+
+ {Math.round(row.rawConfidence * 100)}% + + {row.weight.toFixed(1)} + + {Math.round(row.weightedContribution * 100)}% +
Derived Total + + {totalWeight.toFixed(1)} + + {Math.round(group.derived_confidence * 100)}% +
+
+
+
+
+ + {/* ── Sidebar Column ──────────────────────────────── */} +
+ {/* Corroboration Badge */} + + +
+ + + Corroboration + +
+
+ + {group.corroboration_met ? ( +
+ +
+

+ Corroborated +

+

+ {distinctSources} of {minSources} required source{minSources !== 1 ? "s" : ""} confirmed +

+
+
+ ) : ( +
+ +
+

+ Pending Corroboration {distinctSources}/{minSources} +

+

+ {minSources - distinctSources} more distinct source{(minSources - distinctSources) !== 1 ? "s" : ""} needed +

+
+
+ )} +
+
+ + {/* Linked Mitigation */} + + +
+ + + Linked Mitigation + +
+
+ + {group.mitigation_id ? ( +
+ +
+ + {group.mitigation_id.slice(0, 8)}… + + + Active + +
+

+ Mitigation created from this signal group +

+ +
+ ) : ( +
+ +
+

+ No mitigation created +

+

+ {group.status === "open" + ? "Corroboration threshold has not been met yet. A mitigation will be created when enough distinct sources confirm this attack." + : group.status === "expired" + ? "The correlation window expired before corroboration was achieved. No mitigation was triggered." + : "No linked mitigation is available for this signal group."} +

+
+
+ )} +
+
+ + {/* Confidence Summary */} + + +
+ + + Summary + +
+
+ +
+

+ Derived Confidence +

+
+
+
+
+ + {Math.round(group.derived_confidence * 100)}% + +
+
+
+

+ Distinct Sources +

+

{distinctSources}

+
+
+

+ Confidence Threshold +

+

+ {correlationConfig + ? `${Math.round(correlationConfig.confidence_threshold * 100)}%` + : "—"} +

+
+
+

+ Min Sources Required +

+

{minSources}

+
+ + +
+
+
+ + ) +} diff --git a/frontend/app/(dashboard)/correlation/page.tsx b/frontend/app/(dashboard)/correlation/page.tsx new file mode 100644 index 0000000..92f9dae --- /dev/null +++ b/frontend/app/(dashboard)/correlation/page.tsx @@ -0,0 +1,65 @@ +"use client" + +import { useState } from "react" +import { DashboardLayout } from "@/components/dashboard/dashboard-layout" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { Radio, Layers, Settings } from "lucide-react" +import { useOpenSignalGroupCount } from "@/hooks/use-api" +import { SignalsTab } from "@/components/dashboard/correlation/signals-tab" +import { GroupsTab } from "@/components/dashboard/correlation/groups-tab" +import { ConfigTab } from "@/components/dashboard/correlation/config-tab" + +export default function CorrelationPage() { + const [activeTab, setActiveTab] = useState("signals") + const openGroupCount = useOpenSignalGroupCount() + + return ( + +
+
+
+

Correlation

+

+ Multi-signal correlation engine — combine signals from multiple sources +

+
+ + + + + + Signals + + + + Groups + {openGroupCount > 0 && ( + + {openGroupCount} + + )} + + + + Config + + + + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/frontend/app/(dashboard)/mitigations/[id]/page.tsx b/frontend/app/(dashboard)/mitigations/[id]/page.tsx index 77d9dc4..e0ef42c 100644 --- a/frontend/app/(dashboard)/mitigations/[id]/page.tsx +++ b/frontend/app/(dashboard)/mitigations/[id]/page.tsx @@ -4,13 +4,14 @@ import { use } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { DashboardLayout } from "@/components/dashboard/dashboard-layout" -import { useMitigation, useConfigInventory } from "@/hooks/use-api" +import { useMitigation, useConfigInventory, useSignalGroupDetail } from "@/hooks/use-api" import { StatusBadge } from "@/components/dashboard/status-badge" import { ActionBadge } from "@/components/dashboard/action-badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import { ArrowLeft, Check, Clock, Copy, FileText, ShieldAlert, Activity, GitBranch, RefreshCw, AlertTriangle, User, Terminal } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { ArrowLeft, Check, Clock, Copy, FileText, ShieldAlert, Activity, GitBranch, RefreshCw, AlertTriangle, User, Terminal, Layers, CheckCircle2, Info } from "lucide-react" import { FlowSpecPreview, formatFlowSpecRule } from "@/components/dashboard/flowspec-preview" import { IncidentReportDialog } from "@/components/dashboard/incident-report-dialog" import { withdrawMitigation, getIncidentReport } from "@/lib/api" @@ -68,6 +69,9 @@ export default function MitigationDetailPage({ params }: { params: Promise<{ id: const { data: mitigation, isLoading, mutate } = useMitigation(id) const { data: inventory } = useConfigInventory() + const { data: signalGroupDetail, isLoading: isSignalGroupLoading } = useSignalGroupDetail( + mitigation?.correlation?.signal_group_id ?? null + ) const [copied, setCopied] = useState(null) const [showWithdrawDialog, setShowWithdrawDialog] = useState(false) @@ -338,6 +342,129 @@ export default function MitigationDetailPage({ params }: { params: Promise<{ id: + {/* Correlation Section */} + {mitigation.correlation ? ( + + +
+ + Correlation + {mitigation.correlation.corroboration_met ? ( + + + Corroborated + + ) : ( + + + Pending + + )} +
+
+ + {/* Signal Group Link */} +
+

Signal Group

+ + {mitigation.correlation.signal_group_id} + +
+ + {/* Derived Confidence with visual bar */} +
+

Derived Confidence

+
+
+
+
+ + {Math.round(mitigation.correlation.derived_confidence * 100)}% + +
+
+ + {/* Source Count with source names */} +
+

Sources

+
+ {mitigation.correlation.source_count} source{mitigation.correlation.source_count !== 1 ? "s" : ""} + {mitigation.correlation.contributing_sources?.map((source) => ( + + {source} + + ))} +
+
+ + {/* Contributing Sources Table (from signal group detail) */} + {isSignalGroupLoading ? ( +
+

Contributing Sources

+
+ + + +
+
+ ) : signalGroupDetail?.events && signalGroupDetail.events.length > 0 ? ( +
+

Contributing Sources

+
+ + + + + + + + + + {signalGroupDetail.events.map((event) => ( + + + + + + ))} + +
SourceConfidenceWeight
{event.source} + {event.confidence != null ? `${Math.round(event.confidence * 100)}%` : "N/A"} + {event.source_weight.toFixed(1)}
+
+
+ ) : null} + + {/* Why Explanation */} + {mitigation.correlation.explanation && ( +
+

Why

+

+ {mitigation.correlation.explanation} +

+
+ )} + + + ) : ( + + +
+ +

+ Single-source mitigation — no correlation data available. +

+
+
+
+ )} +
{/* Sidebar Column */} diff --git a/frontend/components/dashboard/command-palette.tsx b/frontend/components/dashboard/command-palette.tsx index ea83148..adf9d0e 100644 --- a/frontend/components/dashboard/command-palette.tsx +++ b/frontend/components/dashboard/command-palette.tsx @@ -13,7 +13,7 @@ import { CommandSeparator, CommandShortcut, } from "@/components/ui/command" -import { LayoutDashboard, Shield, ShieldAlert, Activity, FileText, Settings, Zap, Clock, XCircle, Database, FileCode, History } from "lucide-react" +import { LayoutDashboard, Shield, ShieldAlert, Activity, FileText, Settings, Zap, Clock, XCircle, Database, FileCode, History, Waypoints } from "lucide-react" import { useMitigations, useEvents } from "@/hooks/use-api" import type { Mitigation } from "@/lib/api" @@ -103,6 +103,11 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { Events g e + runCommand(() => router.push("/correlation"))} className="font-mono text-xs"> + + Correlation + g r + runCommand(() => router.push("/inventory"))} className="font-mono text-xs"> Inventory diff --git a/frontend/components/dashboard/correlation/config-tab.tsx b/frontend/components/dashboard/correlation/config-tab.tsx new file mode 100644 index 0000000..081e71e --- /dev/null +++ b/frontend/components/dashboard/correlation/config-tab.tsx @@ -0,0 +1,564 @@ +"use client" + +import { useState, useCallback, useEffect } from "react" +import { toast } from "sonner" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useCorrelationConfig, useConfigPlaybooks } from "@/hooks/use-api" +import { usePermissions } from "@/hooks/use-permissions" +import { updateCorrelationConfig } from "@/lib/api" +import type { CorrelationConfig, SourceConfig } from "@/lib/api" +import { Settings, Plus, Pencil, Trash2, Save, Loader2, AlertCircle, Link as LinkIcon } from "lucide-react" +import Link from "next/link" + +export function ConfigTab() { + return ( +
+ + + +
+ ) +} + +// ── Correlation Settings Editor ──────────────────────────── + +function CorrelationSettingsEditor() { + const { data: config, error, isLoading, mutate } = useCorrelationConfig() + const { isAdmin } = usePermissions() + const [saving, setSaving] = useState(false) + const [formState, setFormState] = useState(null) + const [validationErrors, setValidationErrors] = useState([]) + + // Initialize form state from fetched config + const form = formState ?? config + + const handleFieldChange = (field: keyof CorrelationConfig, value: unknown) => { + if (!form) return + const updated = { ...form, [field]: value } + setFormState(updated) + setValidationErrors(validateConfig(updated)) + } + + const handleSave = useCallback(async () => { + if (!formState) return + const errors = validateConfig(formState) + if (errors.length > 0) { + setValidationErrors(errors) + return + } + + setSaving(true) + try { + await updateCorrelationConfig(formState) + await mutate() + setFormState(null) + toast.success("Correlation config saved") + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to save config") + } finally { + setSaving(false) + } + }, [formState, mutate]) + + if (isLoading) { + return ( + + + + + + + + + ) + } + + if (error) { + return ( + + + + Failed to load correlation config + + + ) + } + + if (!form) return null + + const isDirty = formState != null + const readOnly = !isAdmin + + return ( + + +
+ + + Correlation Settings + + + {form.enabled ? "Enabled" : "Disabled"} + +
+
+ +
+
+ + handleFieldChange("window_seconds", parseInt(e.target.value) || 0)} + disabled={readOnly} + className="h-8 text-xs font-mono" + min={1} + /> +
+
+ + handleFieldChange("min_sources", parseInt(e.target.value) || 0)} + disabled={readOnly} + className="h-8 text-xs font-mono" + min={1} + /> +
+
+ + handleFieldChange("confidence_threshold", parseFloat(e.target.value) || 0)} + disabled={readOnly} + className="h-8 text-xs font-mono" + min={0} + max={1} + step={0.1} + /> +
+
+ + {validationErrors.length > 0 && ( +
+ {validationErrors.map((err, i) => ( +

{err}

+ ))} +
+ )} + + {isAdmin && isDirty && ( +
+ + +
+ )} + + {readOnly && ( +

+ Admin access required to edit settings +

+ )} +
+
+ ) +} + +// ── Signal Source CRUD Cards ──────────────────────────── + +function SignalSourceCards() { + const { data: config, mutate } = useCorrelationConfig() + const { isAdmin } = usePermissions() + const [editingSource, setEditingSource] = useState<{ name: string; config: SourceConfig } | null>(null) + const [addingSource, setAddingSource] = useState(false) + const [deleteConfirm, setDeleteConfirm] = useState(null) + const [deleting, setDeleting] = useState(false) + + if (!config) return null + + const sources = Object.entries(config.sources ?? {}) + + const handleDeleteSource = async (name: string) => { + if (!config) return + setDeleting(true) + const updated = { ...config, sources: { ...config.sources } } + delete updated.sources[name] + try { + await updateCorrelationConfig(updated) + await mutate() + toast.success(`Removed source "${name}"`) + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to remove source") + } finally { + setDeleting(false) + setDeleteConfirm(null) + } + } + + const handleSaveSource = async (name: string, sourceConfig: SourceConfig, isNew: boolean) => { + if (!config) return + const updated = { + ...config, + sources: { ...config.sources, [name]: sourceConfig }, + } + try { + await updateCorrelationConfig(updated) + await mutate() + toast.success(isNew ? `Added source "${name}"` : `Updated source "${name}"`) + setEditingSource(null) + setAddingSource(false) + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to save source") + } + } + + return ( + + +
+ + Signal Sources + + {isAdmin && ( + + )} +
+
+ + {sources.length === 0 ? ( +

+ No signal sources configured +

+ ) : ( +
+ {sources.map(([name, src]) => ( +
+
+ {name} + + {src.type || "unknown"} + +
+
+
+ Weight + {src.weight.toFixed(1)} +
+ {Object.keys(src.confidence_mapping).length > 0 && ( +
+ Mappings + + {Object.keys(src.confidence_mapping).length} + +
+ )} +
+ {isAdmin && ( +
+ + +
+ )} +
+ ))} +
+ )} +
+ + {/* Add/Edit Dialog */} + { setAddingSource(false); setEditingSource(null) }} + onSave={handleSaveSource} + initialName={editingSource?.name} + initialConfig={editingSource?.config} + isNew={addingSource} + /> + + {/* Delete Confirmation Dialog */} + { if (!open) setDeleteConfirm(null) }}> + + + Remove Signal Source + + This will remove the signal source{" "} + {deleteConfirm}{" "} + and its weight configuration. Events from this source will still be accepted + using the default weight. + + + + Cancel + deleteConfirm && handleDeleteSource(deleteConfirm)} + disabled={deleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleting ? "Removing..." : "Remove"} + + + + +
+ ) +} + +function SourceDialog({ + open, + onClose, + onSave, + initialName, + initialConfig, + isNew, +}: { + open: boolean + onClose: () => void + onSave: (name: string, config: SourceConfig, isNew: boolean) => Promise + initialName?: string + initialConfig?: SourceConfig + isNew: boolean +}) { + const [name, setName] = useState(initialName || "") + const [weight, setWeight] = useState(initialConfig?.weight?.toString() || "1.0") + const [type, setType] = useState(initialConfig?.type || "detector") + const [saving, setSaving] = useState(false) + + // Reset form when opened + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + onClose() + } else { + setName(initialName || "") + setWeight(initialConfig?.weight?.toString() || "1.0") + setType(initialConfig?.type || "detector") + } + } + + // Sync form when initialName/initialConfig change + useEffect(() => { + setName(initialName || "") + setWeight(initialConfig?.weight?.toString() || "1.0") + setType(initialConfig?.type || "detector") + }, [initialName, initialConfig]) + + const handleSave = async () => { + if (!name.trim()) return + setSaving(true) + try { + await onSave( + name.trim(), + { + weight: parseFloat(weight) || 1.0, + type, + confidence_mapping: initialConfig?.confidence_mapping ?? {}, + }, + isNew, + ) + } finally { + setSaving(false) + } + } + + return ( + + + + + {isNew ? "Add Signal Source" : `Edit Source: ${initialName}`} + + +
+
+ + setName(e.target.value)} + disabled={!isNew} + placeholder="e.g., fastnetmon" + className="h-8 text-xs font-mono" + /> +
+
+ + +
+
+ + setWeight(e.target.value)} + className="h-8 text-xs font-mono" + min={0} + step={0.1} + /> +
+
+ + + + +
+
+ ) +} + +// ── Per-Playbook Overrides ──────────────────────────── + +function PlaybookOverrides() { + const { data: playbooksData } = useConfigPlaybooks() + + if (!playbooksData) return null + + // Check which playbooks have correlation overrides + // The playbooks API doesn't currently expose correlation field, + // so we show a read-only display with a link to the Playbooks tab + const playbooks = playbooksData.playbooks + + return ( + + +
+ + Per-Playbook Overrides + + + + Edit in Playbooks + +
+
+ + {playbooks.length === 0 ? ( +

+ No playbooks configured.{" "} + + Configure playbooks + +

+ ) : ( +
+ {playbooks.map((playbook) => ( +
+
+ {playbook.name} + + {playbook.match.vector.replace(/_/g, " ")} + +
+ + Uses global defaults + +
+ ))} +
+ )} +
+
+ ) +} + +// ── Validation ──────────────────────────── + +function validateConfig(config: CorrelationConfig): string[] { + const errors: string[] = [] + if (config.window_seconds < 1) errors.push("Window must be at least 1 second") + if (config.min_sources < 1) errors.push("Min sources must be at least 1") + if (config.confidence_threshold < 0 || config.confidence_threshold > 1) + errors.push("Confidence threshold must be between 0 and 1") + if (config.default_weight < 0) errors.push("Default weight must be non-negative") + return errors +} diff --git a/frontend/components/dashboard/correlation/groups-tab.tsx b/frontend/components/dashboard/correlation/groups-tab.tsx new file mode 100644 index 0000000..c4f29c5 --- /dev/null +++ b/frontend/components/dashboard/correlation/groups-tab.tsx @@ -0,0 +1,252 @@ +"use client" + +import { useState, useCallback } from "react" +import { useSearchParams, useRouter } from "next/navigation" +import Link from "next/link" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useSignalGroupsPaginated } from "@/hooks/use-api" +import { Layers, AlertCircle, Loader2, XCircle } from "lucide-react" +import { SignalGroupStatusBadge, formatRelativeTime } from "./signals-tab" + +const STATUS_OPTIONS = [ + { value: "all", label: "All statuses" }, + { value: "open", label: "Open" }, + { value: "resolved", label: "Resolved" }, + { value: "expired", label: "Expired" }, +] + +const VECTOR_OPTIONS = [ + { value: "all", label: "All vectors" }, + { value: "udp_flood", label: "UDP Flood" }, + { value: "syn_flood", label: "SYN Flood" }, + { value: "ntp_amplification", label: "NTP Amplification" }, + { value: "dns_amplification", label: "DNS Amplification" }, + { value: "memcached_amplification", label: "Memcached Amplification" }, + { value: "ssdp_amplification", label: "SSDP Amplification" }, + { value: "icmp_flood", label: "ICMP Flood" }, +] + +export function GroupsTab() { + const searchParams = useSearchParams() + const router = useRouter() + + // Read filters from URL params with sensible defaults + const [status, setStatus] = useState(searchParams.get("status") || "open") + const [vector, setVector] = useState(searchParams.get("vector") || "all") + + // Sync URL params when filters change + const updateUrlParams = useCallback( + (newStatus: string, newVector: string) => { + const params = new URLSearchParams() + if (newStatus !== "open") params.set("status", newStatus) + if (newVector !== "all") params.set("vector", newVector) + const query = params.toString() + router.replace(`/correlation${query ? `?${query}` : ""}`, { scroll: false }) + }, + [router], + ) + + const handleStatusChange = (val: string) => { + setStatus(val) + updateUrlParams(val, vector) + } + + const handleVectorChange = (val: string) => { + setVector(val) + updateUrlParams(status, val) + } + + const clearFilters = () => { + setStatus("open") + setVector("all") + updateUrlParams("open", "all") + } + + const hasActiveFilters = status !== "open" || vector !== "all" + + const filterParams = { + status: status === "all" ? undefined : status, + vector: vector === "all" ? undefined : vector, + limit: 25, + } + + const { data, error, isLoading, isValidating, size, setSize } = useSignalGroupsPaginated(filterParams) + + const groups = data ? data.flatMap((page) => page.groups) : [] + const hasMore = data ? data[data.length - 1]?.has_more ?? false : false + const isLoadingMore = isValidating && size > 1 + + return ( +
+ {/* Filters */} +
+ + + + + {hasActiveFilters && ( + + )} +
+ + {/* Content */} + {isLoading ? ( + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + ) : error ? ( + + + + Failed to load signal groups + + + ) : groups.length === 0 ? ( + + + +

No signal groups found

+

+ {hasActiveFilters + ? "Try adjusting your filters" + : "Signal groups will appear here when events are correlated"} +

+ {hasActiveFilters && ( + + )} +
+
+ ) : ( + + +
+ + + + + + + + + + + + + + {groups.map((group) => ( + + + + + + + + + + ))} + +
StatusVictim IPVectorConfidenceSourcesCreatedWindow
+ + + + {group.victim_ip} + + {group.vector.replace(/_/g, " ")} + + {group.source_count} + {formatRelativeTime(group.created_at)} + + {group.status === "open" + ? formatRelativeTime(group.window_expires_at) + : "—"} +
+
+ + {/* Pagination */} + {hasMore && ( +
+ +
+ )} +
+
+ )} +
+ ) +} + +function ConfidenceDisplay({ confidence }: { confidence: number }) { + const pct = Math.round(confidence * 100) + let colorClass = "text-muted-foreground" + if (pct >= 80) colorClass = "text-green-600 dark:text-green-400" + else if (pct >= 50) colorClass = "text-yellow-600 dark:text-yellow-400" + else colorClass = "text-red-600 dark:text-red-400" + + return {pct}% +} diff --git a/frontend/components/dashboard/correlation/signals-tab.tsx b/frontend/components/dashboard/correlation/signals-tab.tsx new file mode 100644 index 0000000..8c44b2f --- /dev/null +++ b/frontend/components/dashboard/correlation/signals-tab.tsx @@ -0,0 +1,286 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { useSignalSources, useSignalGroups } from "@/hooks/use-api" +import { Radio, AlertCircle, Layers } from "lucide-react" +import Link from "next/link" + +function SourceStatusCards() { + const { data: sources, error, isLoading } = useSignalSources() + + if (isLoading) { + return ( +
+ {[1, 2, 3].map((i) => ( + + + + + + + + ))} +
+ ) + } + + if (error) { + return ( + + + + Failed to load signal sources + + + ) + } + + if (!sources || sources.length === 0) { + return ( + + + +

No signal sources configured

+

+ Configure sources in the Config tab to start receiving signals +

+
+
+ ) + } + + return ( +
+ {sources.map((source) => ( + + +
+
+ + {source.name} +
+ + {source.type} + +
+
+
+ Last seen + + {source.last_seen + ? formatRelativeTime(source.last_seen) + : "Never"} + +
+
+ Events + {source.event_count} +
+
+ Weight + {source.weight.toFixed(1)} +
+
+
+
+ ))} +
+ ) +} + +function SourceWeightVisualization() { + const { data: sources } = useSignalSources() + + if (!sources || sources.length === 0) return null + + const totalWeight = sources.reduce((sum, s) => sum + s.weight, 0) + + return ( + + + + Source Weights + + + +
+ {sources.map((source) => { + const pct = totalWeight > 0 ? (source.weight / totalWeight) * 100 : 0 + return ( +
0 ? "24px" : "0" }} + title={`${source.name}: ${source.weight.toFixed(1)} (${pct.toFixed(0)}%)`} + > + + {source.name} + +
+ ) + })} +
+
+ {sources.map((source) => ( +
+ + {source.name} + {source.weight.toFixed(1)} +
+ ))} +
+
+
+ ) +} + +function RecentSignalsTable() { + const { data: groupsResp, error, isLoading } = useSignalGroups({ limit: 20 }) + + if (isLoading) { + return ( + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + ) + } + + if (error) { + return ( + + + + Failed to load recent signals + + + ) + } + + const groups = groupsResp?.groups ?? [] + + if (groups.length === 0) { + return ( + + + +

No recent signals

+

+ Signals will appear here when events are correlated +

+
+
+ ) + } + + return ( + + + + Recent Signal Groups + + + +
+ + + + + + + + + + + + + {groups.map((group) => ( + + + + + + + + + ))} + +
StatusVictim IPVectorConfidenceSourcesCreated
+ + + + {group.victim_ip} + + {group.vector.replace(/_/g, " ")} + {Math.round(group.derived_confidence * 100)}% + {group.source_count} + {formatRelativeTime(group.created_at)} +
+
+
+
+ ) +} + +export function SignalGroupStatusBadge({ status, corroborated }: { status: string; corroborated?: boolean }) { + if (status === "resolved") { + return ( + + Resolved + + ) + } + if (status === "expired") { + return ( + + Expired + + ) + } + // open + return ( + + {corroborated ? "Corroborated" : "Open"} + + ) +} + +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return "Just now" + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +// Re-export for use in groups tab +export { formatRelativeTime } + +export function SignalsTab() { + return ( +
+ + + +
+ ) +} diff --git a/frontend/components/dashboard/keyboard-shortcuts-modal.tsx b/frontend/components/dashboard/keyboard-shortcuts-modal.tsx index b58ac22..16cc429 100644 --- a/frontend/components/dashboard/keyboard-shortcuts-modal.tsx +++ b/frontend/components/dashboard/keyboard-shortcuts-modal.tsx @@ -14,6 +14,7 @@ const shortcuts = [ { keys: ["g", "o"], description: "Go to Overview" }, { keys: ["g", "m"], description: "Go to Mitigations" }, { keys: ["g", "e"], description: "Go to Events" }, + { keys: ["g", "r"], description: "Go to Correlation" }, { keys: ["g", "i"], description: "Go to Inventory" }, { keys: ["g", "h"], description: "Go to IP History" }, { keys: ["g", "a"], description: "Go to Audit Log" }, diff --git a/frontend/components/dashboard/sidebar.tsx b/frontend/components/dashboard/sidebar.tsx index f653621..c4b6635 100644 --- a/frontend/components/dashboard/sidebar.tsx +++ b/frontend/components/dashboard/sidebar.tsx @@ -3,15 +3,16 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" -import { LayoutDashboard, Shield, Activity, FileText, Settings, X, ChevronsLeft, ChevronsRight, FileCode, Database, History } from "lucide-react" +import { LayoutDashboard, Shield, Activity, FileText, Settings, X, ChevronsLeft, ChevronsRight, FileCode, Database, History, Waypoints } from "lucide-react" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { usePermissions } from "@/hooks/use-permissions" -import { useStats } from "@/hooks/use-api" +import { useStats, useOpenSignalGroupCount } from "@/hooks/use-api" const navItems = [ { href: "/", label: "Overview", icon: LayoutDashboard, adminOnly: false }, { href: "/mitigations", label: "Mitigations", icon: Shield, adminOnly: false }, { href: "/events", label: "Events", icon: Activity, adminOnly: false }, + { href: "/correlation", label: "Correlation", icon: Waypoints, adminOnly: false }, { href: "/inventory", label: "Inventory", icon: Database, adminOnly: false }, { href: "/ip-history", label: "IP History", icon: History, adminOnly: false }, { href: "/audit-log", label: "Audit Log", icon: FileText, adminOnly: false }, @@ -30,6 +31,7 @@ export function Sidebar({ isOpen, onClose, isCollapsed = false, onToggleCollapse const pathname = usePathname() const permissions = usePermissions() const { data: stats } = useStats() + const openGroupCount = useOpenSignalGroupCount() // Filter nav items based on permissions const visibleNavItems = navItems.filter(item => !item.adminOnly || permissions.isAdmin) @@ -74,7 +76,11 @@ export function Sidebar({ isOpen, onClose, isCollapsed = false, onToggleCollapse