Implemented the next roadmap step: direct Graylog MCP replay with temporary baselines.
This commit is contained in:
18
README.md
18
README.md
@@ -95,6 +95,24 @@ Replay uses a temporary SQLite baseline and evaluates events in timestamp order.
|
|||||||
It reports detector counts and the findings that would have been generated. Use
|
It reports detector counts and the findings that would have been generated. Use
|
||||||
the configured stream ID so the export is evaluated with that stream's profile.
|
the configured stream ID so the export is evaluated with that stream's profile.
|
||||||
|
|
||||||
|
Replay directly from Graylog MCP without touching the live baseline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signalscope replay-graylog --range-seconds 86400
|
||||||
|
```
|
||||||
|
|
||||||
|
By default this uses the enabled streams from the dashboard configuration. Limit
|
||||||
|
the run to one or more streams with repeated `--stream-id` flags. To test a
|
||||||
|
candidate detector/profile configuration before applying it, compare it against
|
||||||
|
the current runtime config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signalscope replay-graylog --range-seconds 86400 --compare-config-file exports/candidate-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The comparison reports detector-count, field-finding, and source-anomaly deltas
|
||||||
|
using the same fetched event window.
|
||||||
|
|
||||||
The current MCP endpoint is `http://<graylog-host>:9000/api/mcp`. Enable it in
|
The current MCP endpoint is `http://<graylog-host>:9000/api/mcp`. Enable it in
|
||||||
Graylog under `System -> Configurations -> MCP` and use stream IDs internally;
|
Graylog under `System -> Configurations -> MCP` and use stream IDs internally;
|
||||||
the fgAI stream picker resolves titles in the UI.
|
the fgAI stream picker resolves titles in the UI.
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ Goal: make findings more accurate before adding more integrations.
|
|||||||
- [ ] Add sequence detection, for example DNS lookup -> outbound connection -> authentication event.
|
- [ ] Add sequence detection, for example DNS lookup -> outbound connection -> authentication event.
|
||||||
- [x] Add per-stream detector enablement and thresholds in the UI.
|
- [x] Add per-stream detector enablement and thresholds in the UI.
|
||||||
- [x] Add a dry-run replay command for historic JSONL or Graylog exports using temporary baselines.
|
- [x] Add a dry-run replay command for historic JSONL or Graylog exports using temporary baselines.
|
||||||
- [ ] Add direct Graylog MCP time-range replay and result comparison against saved detector configurations.
|
- [x] Add direct Graylog MCP time-range replay and result comparison against saved detector configurations.
|
||||||
|
- [ ] Add dashboard controls for launching safe replay jobs and viewing detector deltas.
|
||||||
|
|
||||||
Acceptance: each finding shows its detector, confidence, baseline sample count, current value, expected value, and a bounded set of raw-event references.
|
Acceptance: each finding shows its detector, confidence, baseline sample count, current value, expected value, and a bounded set of raw-event references.
|
||||||
|
|
||||||
|
|||||||
104
src/fgai/cli.py
104
src/fgai/cli.py
@@ -16,7 +16,9 @@ from .syslog_server import listen_udp_syslog
|
|||||||
from .monitor import monitor_loop
|
from .monitor import monitor_loop
|
||||||
from .threat_intel import enrich_ips, is_public_ip
|
from .threat_intel import enrich_ips, is_public_ip
|
||||||
from .config import ConfigStore
|
from .config import ConfigStore
|
||||||
from .replay import replay_events
|
from .graylog_mcp import GraylogMcpClient
|
||||||
|
from .graylog_source import GraylogStreamSource
|
||||||
|
from .replay import replay_comparison, replay_events
|
||||||
from .stream_profiles import parse_profiles
|
from .stream_profiles import parse_profiles
|
||||||
|
|
||||||
|
|
||||||
@@ -218,11 +220,97 @@ def run_dashboard(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_name(config: dict[str, object], stream_id: str) -> str:
|
||||||
|
return next((str(item.get("title", "")) for item in config.get("graylog_streams", []) if isinstance(item, dict) and item.get("id") == stream_id), stream_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_fields(profile: object | None) -> tuple[str, ...]:
|
||||||
|
if not profile:
|
||||||
|
return ()
|
||||||
|
return tuple(
|
||||||
|
field for field in (
|
||||||
|
str(getattr(profile, "entity_field", "")),
|
||||||
|
str(getattr(profile, "timestamp_field", "")),
|
||||||
|
*tuple(str(item) for item in getattr(profile, "categorical_fields", ())),
|
||||||
|
*tuple(str(item) for item in getattr(profile, "numeric_fields", ())),
|
||||||
|
)
|
||||||
|
if field
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_streams(config: dict[str, object], selected: list[str] | None = None) -> list[dict[str, str]]:
|
||||||
|
selected_order = list(dict.fromkeys(item for item in selected or [] if item))
|
||||||
|
selected_set = set(selected_order)
|
||||||
|
configured = [
|
||||||
|
{"id": str(item.get("id", "")), "title": str(item.get("title", ""))}
|
||||||
|
for item in config.get("graylog_streams", [])
|
||||||
|
if isinstance(item, dict) and item.get("id") and (item.get("enabled") or selected_set)
|
||||||
|
]
|
||||||
|
if selected_set:
|
||||||
|
configured = [item for item in configured if item["id"] in selected_set]
|
||||||
|
by_id = {item["id"]: item for item in configured}
|
||||||
|
configured = [by_id.get(stream_id, {"id": stream_id, "title": _stream_name(config, stream_id)}) for stream_id in selected_order]
|
||||||
|
if not configured and str(config.get("graylog_stream", "")):
|
||||||
|
stream_id = str(config.get("graylog_stream", ""))
|
||||||
|
configured = [{"id": stream_id, "title": _stream_name(config, stream_id)}]
|
||||||
|
return configured
|
||||||
|
|
||||||
|
|
||||||
def replay_history(args: argparse.Namespace) -> int:
|
def replay_history(args: argparse.Namespace) -> int:
|
||||||
config = ConfigStore(args.config_file).read()
|
config = ConfigStore(args.config_file).read()
|
||||||
profiles = parse_profiles(config.get("graylog_stream_profiles", []))
|
profiles = parse_profiles(config.get("graylog_stream_profiles", []))
|
||||||
stream_name = next((str(item.get("title", "")) for item in config.get("graylog_streams", []) if isinstance(item, dict) and item.get("id") == args.stream_id), args.stream_id)
|
stream_name = _stream_name(config, args.stream_id)
|
||||||
result = replay_events(read_events(args.logs), profiles, stream_id=args.stream_id, stream_name=stream_name, bucket_seconds=args.bucket_seconds)
|
events = read_events(args.logs)
|
||||||
|
result = replay_events(events, profiles, stream_id=args.stream_id, stream_name=stream_name, bucket_seconds=args.bucket_seconds)
|
||||||
|
if args.compare_config_file:
|
||||||
|
candidate_config = ConfigStore(args.compare_config_file).read()
|
||||||
|
candidate_profiles = parse_profiles(candidate_config.get("graylog_stream_profiles", []))
|
||||||
|
candidate = replay_events(events, candidate_profiles, stream_id=args.stream_id, stream_name=_stream_name(candidate_config, args.stream_id), bucket_seconds=args.bucket_seconds)
|
||||||
|
result = {"current": result, "candidate": candidate, "comparison": replay_comparison(result, candidate), "candidate_config_file": args.compare_config_file}
|
||||||
|
_print_json(result)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def replay_graylog(args: argparse.Namespace) -> int:
|
||||||
|
config = ConfigStore(args.config_file).read()
|
||||||
|
url, token = str(config.get("graylog_mcp_url", "")), str(config.get("graylog_mcp_token", ""))
|
||||||
|
if not url or not token:
|
||||||
|
raise RuntimeError("graylog_mcp_not_configured")
|
||||||
|
profiles = parse_profiles(config.get("graylog_stream_profiles", []))
|
||||||
|
candidate_profiles = {}
|
||||||
|
if args.compare_config_file:
|
||||||
|
candidate_config = ConfigStore(args.compare_config_file).read()
|
||||||
|
candidate_profiles = parse_profiles(candidate_config.get("graylog_stream_profiles", []))
|
||||||
|
streams = _configured_streams(config, args.stream_id)
|
||||||
|
if not streams:
|
||||||
|
raise RuntimeError("no_graylog_streams_selected")
|
||||||
|
events = []
|
||||||
|
stream_statuses = []
|
||||||
|
for stream in streams:
|
||||||
|
stream_id = stream["id"]
|
||||||
|
profile_fields = tuple(dict.fromkeys([*_profile_fields(profiles.get(stream_id)), *_profile_fields(candidate_profiles.get(stream_id))]))
|
||||||
|
stream_events, status = GraylogStreamSource(
|
||||||
|
GraylogMcpClient(url, token),
|
||||||
|
stream_id,
|
||||||
|
str(config.get("graylog_query", "*")),
|
||||||
|
str(config.get("graylog_field_mapping", "")),
|
||||||
|
stream.get("title") or stream_id,
|
||||||
|
profile_fields,
|
||||||
|
).fetch(max_events=args.max_events_per_stream, range_seconds=args.range_seconds)
|
||||||
|
events.extend(stream_events)
|
||||||
|
stream_statuses.append({"stream_id": stream_id, "stream_name": stream.get("title") or stream_id, **status})
|
||||||
|
result = {
|
||||||
|
"source": "graylog_mcp",
|
||||||
|
"range_seconds": args.range_seconds,
|
||||||
|
"streams": stream_statuses,
|
||||||
|
"replay": replay_events(events, profiles, bucket_seconds=args.bucket_seconds),
|
||||||
|
}
|
||||||
|
if args.compare_config_file:
|
||||||
|
candidate = replay_events(events, candidate_profiles, bucket_seconds=args.bucket_seconds)
|
||||||
|
current = result["replay"]
|
||||||
|
result["candidate_config_file"] = args.compare_config_file
|
||||||
|
result["candidate_replay"] = candidate
|
||||||
|
result["comparison"] = replay_comparison(current, candidate)
|
||||||
_print_json(result)
|
_print_json(result)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -322,8 +410,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
replay.add_argument("--config-file", default="state/fgai-config.json", help="Stream profile configuration")
|
replay.add_argument("--config-file", default="state/fgai-config.json", help="Stream profile configuration")
|
||||||
replay.add_argument("--stream-id", default="", help="Apply this configured Graylog stream profile to exported events")
|
replay.add_argument("--stream-id", default="", help="Apply this configured Graylog stream profile to exported events")
|
||||||
replay.add_argument("--bucket-seconds", type=int, default=300, help="Replay baseline bucket size")
|
replay.add_argument("--bucket-seconds", type=int, default=300, help="Replay baseline bucket size")
|
||||||
|
replay.add_argument("--compare-config-file", default="", help="Replay the same export against another saved config and report detector deltas")
|
||||||
replay.set_defaults(func=replay_history)
|
replay.set_defaults(func=replay_history)
|
||||||
|
|
||||||
|
replay_mcp = subparsers.add_parser("replay-graylog", help="Replay a Graylog MCP time window against temporary baselines")
|
||||||
|
replay_mcp.add_argument("--config-file", default="state/fgai-config.json", help="Runtime configuration with Graylog MCP URL, token, streams and profiles")
|
||||||
|
replay_mcp.add_argument("--stream-id", action="append", default=[], help="Replay only this stream ID. Repeat for multiple streams. Defaults to enabled streams.")
|
||||||
|
replay_mcp.add_argument("--range-seconds", type=int, default=24 * 3600, help="Graylog relative time window to fetch")
|
||||||
|
replay_mcp.add_argument("--max-events-per-stream", type=int, default=5_000, help="Maximum events fetched from each selected stream")
|
||||||
|
replay_mcp.add_argument("--bucket-seconds", type=int, default=300, help="Replay baseline bucket size")
|
||||||
|
replay_mcp.add_argument("--compare-config-file", default="", help="Replay the same Graylog events against another saved config and report detector deltas")
|
||||||
|
replay_mcp.set_defaults(func=replay_graylog)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ class GraylogStreamSource:
|
|||||||
if not isinstance(self.mapping, dict):
|
if not isinstance(self.mapping, dict):
|
||||||
raise RuntimeError("invalid_graylog_field_mapping")
|
raise RuntimeError("invalid_graylog_field_mapping")
|
||||||
|
|
||||||
def fetch(self, *, max_events: int = 5_000) -> tuple[list[LogEvent], dict[str, object]]:
|
def fetch(self, *, max_events: int = 5_000, range_seconds: int = 300) -> tuple[list[LogEvent], dict[str, object]]:
|
||||||
status = self.client.probe()
|
status = self.client.probe()
|
||||||
mapping_fields = [str(value) for value in self.mapping.values() if isinstance(value, str)]
|
mapping_fields = [str(value) for value in self.mapping.values() if isinstance(value, str)]
|
||||||
arguments: dict[str, object] = {
|
arguments: dict[str, object] = {
|
||||||
"query": self.query,
|
"query": self.query,
|
||||||
"range_seconds": 300,
|
"range_seconds": max(1, int(range_seconds)),
|
||||||
"fields": list(dict.fromkeys([*DEFAULT_FIELDS, *mapping_fields, *self.profile_fields])),
|
"fields": list(dict.fromkeys([*DEFAULT_FIELDS, *mapping_fields, *self.profile_fields])),
|
||||||
}
|
}
|
||||||
if self.stream:
|
if self.stream:
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ from .baseline import BaselineStore
|
|||||||
from .models import LogEvent
|
from .models import LogEvent
|
||||||
|
|
||||||
|
|
||||||
|
def replay_comparison(current: dict[str, object], candidate: dict[str, object]) -> dict[str, object]:
|
||||||
|
"""Summarize how two replay outputs differ."""
|
||||||
|
current_counts = current.get("field_detector_counts", {})
|
||||||
|
candidate_counts = candidate.get("field_detector_counts", {})
|
||||||
|
detectors = sorted(set(current_counts if isinstance(current_counts, dict) else {}) | set(candidate_counts if isinstance(candidate_counts, dict) else {}))
|
||||||
|
detector_deltas = {
|
||||||
|
detector: int((candidate_counts if isinstance(candidate_counts, dict) else {}).get(detector, 0)) - int((current_counts if isinstance(current_counts, dict) else {}).get(detector, 0))
|
||||||
|
for detector in detectors
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"events_delta": int(candidate.get("events", 0)) - int(current.get("events", 0)),
|
||||||
|
"field_findings_delta": len(candidate.get("field_findings", [])) - len(current.get("field_findings", [])),
|
||||||
|
"source_anomalies_delta": len(candidate.get("source_anomalies", [])) - len(current.get("source_anomalies", [])),
|
||||||
|
"detector_count_delta": detector_deltas,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _timestamp(event: LogEvent, fallback: int) -> int:
|
def _timestamp(event: LogEvent, fallback: int) -> int:
|
||||||
value = event.fields.get("eventtime", event.fields.get("timestamp", ""))
|
value = event.fields.get("eventtime", event.fields.get("timestamp", ""))
|
||||||
try:
|
try:
|
||||||
|
|||||||
39
tests/test_cli.py
Normal file
39
tests/test_cli.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from fgai.cli import _configured_streams
|
||||||
|
|
||||||
|
|
||||||
|
class CliTests(unittest.TestCase):
|
||||||
|
def test_configured_streams_uses_enabled_streams_by_default(self):
|
||||||
|
config = {
|
||||||
|
"graylog_streams": [
|
||||||
|
{"id": "fortigate", "title": "Fortigate", "enabled": True},
|
||||||
|
{"id": "adguard", "title": "Adguard", "enabled": False},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.assertEqual(_configured_streams(config), [{"id": "fortigate", "title": "Fortigate"}])
|
||||||
|
|
||||||
|
def test_configured_streams_can_select_disabled_stream(self):
|
||||||
|
config = {
|
||||||
|
"graylog_streams": [
|
||||||
|
{"id": "fortigate", "title": "Fortigate", "enabled": True},
|
||||||
|
{"id": "adguard", "title": "Adguard", "enabled": False},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.assertEqual(_configured_streams(config, ["adguard"]), [{"id": "adguard", "title": "Adguard"}])
|
||||||
|
|
||||||
|
def test_configured_streams_preserves_selected_order(self):
|
||||||
|
config = {
|
||||||
|
"graylog_streams": [
|
||||||
|
{"id": "fortigate", "title": "Fortigate", "enabled": True},
|
||||||
|
{"id": "adguard", "title": "Adguard", "enabled": False},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
_configured_streams(config, ["adguard", "fortigate"]),
|
||||||
|
[{"id": "adguard", "title": "Adguard"}, {"id": "fortigate", "title": "Fortigate"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -30,6 +30,11 @@ class GraylogSourceTests(unittest.TestCase):
|
|||||||
self.assertIn("client", client.arguments["fields"])
|
self.assertIn("client", client.arguments["fields"])
|
||||||
self.assertEqual(client.arguments["offset"], 0)
|
self.assertEqual(client.arguments["offset"], 0)
|
||||||
|
|
||||||
|
def test_uses_requested_range_seconds(self):
|
||||||
|
client = _Client()
|
||||||
|
GraylogStreamSource(client, "vpn").fetch(range_seconds=86_400)
|
||||||
|
self.assertEqual(client.arguments["range_seconds"], 86_400)
|
||||||
|
|
||||||
def test_requests_selected_profile_fields(self):
|
def test_requests_selected_profile_fields(self):
|
||||||
client = _Client()
|
client = _Client()
|
||||||
GraylogStreamSource(client, "windows", profile_fields=("TargetUserName", "EventID")).fetch()
|
GraylogStreamSource(client, "windows", profile_fields=("TargetUserName", "EventID")).fetch()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from fgai.logs import parse_log_line
|
from fgai.logs import parse_log_line
|
||||||
from fgai.replay import replay_events
|
from fgai.replay import replay_comparison, replay_events
|
||||||
from fgai.stream_profiles import parse_profiles
|
from fgai.stream_profiles import parse_profiles
|
||||||
|
|
||||||
|
|
||||||
@@ -19,3 +19,13 @@ class ReplayTests(unittest.TestCase):
|
|||||||
result = replay_events(events, profiles, stream_id="windows")
|
result = replay_events(events, profiles, stream_id="windows")
|
||||||
self.assertEqual(result["events"], 17)
|
self.assertEqual(result["events"], 17)
|
||||||
self.assertGreaterEqual(result["field_detector_counts"].get("auth_failure_burst", 0), 1)
|
self.assertGreaterEqual(result["field_detector_counts"].get("auth_failure_burst", 0), 1)
|
||||||
|
|
||||||
|
def test_replay_comparison_reports_detector_deltas(self):
|
||||||
|
result = replay_comparison(
|
||||||
|
{"events": 10, "field_findings": [{"a": 1}], "source_anomalies": [], "field_detector_counts": {"auth_failure_burst": 2}},
|
||||||
|
{"events": 10, "field_findings": [{"a": 1}, {"a": 2}], "source_anomalies": [{"a": 1}], "field_detector_counts": {"auth_failure_burst": 1, "rare_value": 3}},
|
||||||
|
)
|
||||||
|
self.assertEqual(result["field_findings_delta"], 1)
|
||||||
|
self.assertEqual(result["source_anomalies_delta"], 1)
|
||||||
|
self.assertEqual(result["detector_count_delta"]["auth_failure_burst"], -1)
|
||||||
|
self.assertEqual(result["detector_count_delta"]["rare_value"], 3)
|
||||||
|
|||||||
Reference in New Issue
Block a user