diff --git a/README.md b/README.md index 18dfef4..3d240c3 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,11 @@ checkboxes decide which streams are monitored. Click `Edit profile` on one strea to load its fields and edit only that stream's profile; saving with no active profile editor leaves existing profiles unchanged. +When many Graylog streams are available, use `Diagnostics -> Stream Coverage` to +see which streams are enabled, which have profiles, how many profile fields are +baseline-ready, how many events were fetched, and whether a stream is `ready`, +`learning`, `missing_profile`, `no_events`, or `not_enabled`. + Enabled streams are normalized through the same event model. Stream profiles define the entity, timestamp, categorical, and numeric fields used for baselines. The dashboard and Ollama then correlate behavior across sources, for example a diff --git a/ROADMAP.md b/ROADMAP.md index 28d6682..9c0a40d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,6 +90,7 @@ Acceptance: deployment, restart, upgrade, backup, and monitoring have documented Goal: optimize the UI for security investigation rather than raw tables. - [x] Separate stream enablement from one-profile-at-a-time profile editing. +- [x] Add stream coverage diagnostics for enabled/profiled/ready/no-event streams. - [ ] Replace remaining long tables with compact incident and entity cards where appropriate. - [ ] Add incident filters for stream, severity, entity type, review state, and time range. - [ ] Add baseline versus current charts per selected entity and field. @@ -104,6 +105,7 @@ Acceptance: common triage can be completed from the dashboard without manually p Goal: add log sources and outputs without adding source-specific logic everywhere. - [ ] Define versioned stream-profile templates for FortiGate, Windows, DNS/AdGuard, Nginx, Squid, VPN, and Proxmox. +- [x] Add inventory-style stream coverage to guide which streams need profiles before templates are added. - [ ] Add import/export for profile templates and detector settings. - [ ] Separate source adapters, normalizers, detectors, enrichers, and output adapters into explicit extension interfaces. - [ ] Add optional webhook/SIEM ticket output for confirmed high-severity incidents. diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index 3a1b1fe..d584a5b 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -114,6 +114,9 @@ async function refresh() { const threat = (data.capabilities || {}).threat_intel || {}; const mcp = (data.capabilities || {}).graylog_mcp || {}; const configuration = data.configuration || {}; + const streamCoverage = data.stream_coverage || []; + const enabledStreams = streamCoverage.filter(item => item.enabled); + const streamsMissingProfile = enabledStreams.filter(item => !item.profile_ready).length; const rawCorrelations = data.cross_source_correlations || []; const correlationsCached = rawCorrelations.length === 0 && uiCache.correlations.length > 0; const correlations = rawCorrelations.length ? rawCorrelations : uiCache.correlations; @@ -143,7 +146,7 @@ async function refresh() { `Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}` ].join('
'); document.getElementById('health').innerHTML = [ - metric('Baseline sources ready', baseline.sources_ready || 0), metric('MCP events fetched', mcp.events_fetched || 0), metric('Profiles active', (data.stream_profiles || []).length), metric('Correlated entities', correlations.length) + metric('Enabled streams', enabledStreams.length), metric('Streams missing profile', streamsMissingProfile), metric('MCP events fetched', mcp.events_fetched || 0), metric('Correlated entities', correlations.length) ].join(''); const llmText = llm.text ? esc(llm.text).replace(/\\n/g, '
') : esc(llm.error || 'LLM assessment disabled or waiting for first run.'); document.getElementById('llmAssessment').innerHTML = `
Status: ${esc(llm.status || 'unknown')}

${llmText}

`; @@ -223,6 +226,7 @@ async function refresh() { const profileNames = Object.fromEntries((data.stream_profiles || []).map(item => [item.stream_id, item.name || item.stream_id])); const profileReadiness = (data.profile_readiness || []).map(item => ({...item, profile_name: item.profile_name || profileNames[item.stream_id] || item.stream_id, stream_title: item.stream_name || item.stream_title || streamTitles[item.stream_id] || item.stream_id})); document.getElementById('diagnostics').innerHTML = + '

Stream Coverage

' + table(streamCoverage, [{label:'Stream', key:'stream_name'}, {label:'Enabled', key:'enabled', render:r => r.enabled ? 'yes' : 'no'}, {label:'Profile', render:r => esc(r.profile || 'missing')}, {label:'Entity Field', key:'entity_field'}, {label:'Tracked Fields', key:'tracked_fields'}, {label:'Ready Fields', key:'readiness'}, {label:'Events', key:'events_fetched'}, {label:'Latest Event', key:'latest_event_time'}, {label:'Health', key:'health'}], 'stream-coverage') + '

Cross-Source Correlations

' + table(correlations, [{label:'Entity', key:'entity', render:r => esc(`${r.entity || r.source_ip} (${r.entity_type || 'ip'})`)}, {label:'Streams', render:r => esc((r.streams || []).join(', '))}, {label:'Events', key:'events'}, {label:'Security Events', key:'security_events'}], 'correlations') + '

Entities

' + table(context.source_profiles || [], [{label:'Entity', key:'entity'}, {label:'Events', key:'events'}, {label:'UTM', key:'utm_events'}, {label:'Deny', key:'deny_or_threat_actions'}, {label:'Destinations', key:'distinct_destinations'}, {label:'Actions', render:r => esc((r.top_actions || []).join(', '))}], 'entities') + '

Profile Baseline Readiness

' + table(profileReadiness, [{label:'Profile', key:'profile_name'}, {label:'Stream', key:'stream_title'}, {label:'Field', key:'field'}, {label:'Buckets', key:'buckets'}, {label:'Ready', key:'ready', render:r => r.ready ? 'ready' : 'learning'}], 'profile-readiness') + diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py index 7d34766..3f65374 100644 --- a/src/fgai/monitor.py +++ b/src/fgai/monitor.py @@ -51,6 +51,47 @@ def _range_seconds(value: object) -> int: return 3600 +def _stream_coverage(runtime_values: dict[str, object], stream_profiles: dict[str, object], stream_status: dict[str, object], profile_readiness: list[dict[str, object]], stream_titles: dict[str, str]) -> list[dict[str, object]]: + configured = [ + item for item in runtime_values.get("graylog_streams", []) + if isinstance(item, dict) and item.get("id") + ] + status_by_id = { + str(item.get("stream_id", "")): item + for item in stream_status.get("streams", []) + if isinstance(item, dict) and item.get("stream_id") + } + readiness_by_stream: dict[str, list[dict[str, object]]] = {} + for item in profile_readiness: + readiness_by_stream.setdefault(str(item.get("stream_id", "")), []).append(item) + ids = list(dict.fromkeys([str(item.get("id", "")) for item in configured] + list(stream_profiles) + list(status_by_id))) + rows = [] + for stream_id in ids: + profile = stream_profiles.get(stream_id) + readiness = readiness_by_stream.get(stream_id, []) + ready_fields = sum(1 for item in readiness if item.get("ready")) + total_fields = len(readiness) + status = status_by_id.get(stream_id, {}) + enabled = next((bool(item.get("enabled")) for item in configured if str(item.get("id", "")) == stream_id), False) + rows.append({ + "stream_id": stream_id, + "stream_name": _stream_name(stream_id, stream_titles, profile), + "enabled": enabled, + "profile": _profile_name(stream_id, stream_titles, profile) if profile else "", + "profile_ready": bool(profile), + "entity_field": str(getattr(profile, "entity_field", "")) if profile else "", + "tracked_fields": len(getattr(profile, "categorical_fields", ())) + len(getattr(profile, "numeric_fields", ())) if profile else 0, + "ready_fields": ready_fields, + "total_fields": total_fields, + "readiness": f"{ready_fields}/{total_fields}" if total_fields else "0/0", + "events_fetched": int(status.get("events_fetched", 0) or 0), + "latest_event_time": str(status.get("latest_event_time", "")), + "truncated": bool(status.get("truncated")), + "health": "not_enabled" if not enabled else "missing_profile" if not profile else "no_events" if int(status.get("events_fetched", 0) or 0) == 0 else "learning" if total_fields and ready_fields < total_fields else "ready" if total_fields else "profile_needs_fields", + }) + return rows + + def build_status( log_path: str, *, @@ -146,6 +187,7 @@ def build_status( } for item in profile_readiness ] + stream_coverage = _stream_coverage(runtime_values, stream_profiles, mcp_status, profile_readiness, stream_titles) intel_ips = sorted( { ip @@ -184,6 +226,7 @@ def build_status( "capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status}, "configuration": runtime_config, "stream_profiles": [{"stream_id": item.stream_id, "name": _profile_name(item.stream_id, stream_titles, item), "stream_name": _stream_name(item.stream_id, stream_titles, item), "entity_field": item.entity_field, "timestamp_field": item.timestamp_field, "categorical_fields": list(item.categorical_fields), "numeric_fields": list(item.numeric_fields), "detectors": item.detectors, "field_weights": item.field_weights} for item in stream_profiles.values()], + "stream_coverage": stream_coverage, "profile_readiness": profile_readiness, "diagnostics": { "top_source_ips": top_field_values(events, "srcip", limit=10), diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 7a29d25..c7e791e 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -54,6 +54,29 @@ class MonitorTests(unittest.TestCase): self.assertEqual(status["profile_readiness"][0]["profile_name"], "Fortigate profile") self.assertEqual(status["stream_profiles"][0]["name"], "Fortigate profile") + def test_stream_coverage_marks_missing_profiles(self): + with tempfile.TemporaryDirectory() as tmp: + config_path = Path(tmp) / "config.json" + config_path.write_text( + json.dumps( + { + "graylog_streams": [ + {"id": "firewall", "title": "Firewall", "enabled": True}, + {"id": "dns", "title": "DNS", "enabled": True}, + ], + "graylog_stream_profiles": [{"stream_id": "firewall", "name": "Firewall profile", "entity_field": "srcip", "categorical_fields": ["action"]}], + } + ), + encoding="utf-8", + ) + with patch("fgai.monitor.BaselineStore.profile_readiness", return_value=[{"stream_id": "firewall", "field": "action", "buckets": 12, "ready": True}]): + status = build_status(str(Path(tmp) / "missing.log"), baseline_path=str(Path(tmp) / "baseline.sqlite3"), config_path=str(config_path)) + + coverage = {item["stream_id"]: item for item in status["stream_coverage"]} + self.assertEqual(coverage["firewall"]["health"], "no_events") + self.assertEqual(coverage["firewall"]["readiness"], "1/1") + self.assertEqual(coverage["dns"]["health"], "missing_profile") + def test_add_llm_assessment_records_error_without_ollama(self): status = {"summary": {}, "anomalies": []}