diff --git a/README.md b/README.md index 24c12ae..3801f27 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,21 @@ Graylog event is not learned repeatedly. Related anomalies, profile deviations, and multi-stream correlations are grouped into investigation incidents with a compact evidence timeline. +With a stream profile in place, SignalScope also builds independent burst +baselines for authentication failures, DNS queries, and deny/block actions when +those events are present. These are evaluated per configured entity, so a Windows +account, DNS client, or firewall source is compared to its own history. + +Replay a historic JSONL or Graylog export without changing the live baseline: + +```bash +signalscope replay --logs exports/windows-history.jsonl --stream-id +``` + +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 +the configured stream ID so the export is evaluated with that stream's profile. + The current MCP endpoint is `http://:9000/api/mcp`. Enable it in Graylog under `System -> Configurations -> MCP` and use stream IDs internally; the fgAI stream picker resolves titles in the UI. diff --git a/ROADMAP.md b/ROADMAP.md index cc064c1..bada1b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,11 +25,12 @@ Goal: make findings more accurate before adding more integrations. - [x] Add baseline confidence based on sample count and time-bucket coverage. - [x] Add generic event-rate burst detection per stream/entity. - [x] Add rare-value detection with a minimum historical observation threshold. -- [ ] Add detector-specific authentication failure, DNS volume, and denied-traffic burst thresholds. +- [x] Add detector-specific authentication failure, DNS volume, and denied-traffic burst thresholds. - [ ] Add configurable per-field detector weights. - [ ] Add sequence detection, for example DNS lookup -> outbound connection -> authentication event. -- [ ] Add per-stream detector enablement and thresholds in the UI. -- [ ] Add a dry-run replay command to evaluate detector changes against a selected historic Graylog time range. +- [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. +- [ ] Add direct Graylog MCP time-range replay and result comparison against saved detector configurations. Acceptance: each finding shows its detector, confidence, baseline sample count, current value, expected value, and a bounded set of raw-event references. diff --git a/src/fgai/baseline.py b/src/fgai/baseline.py index dc9bbfb..3db6a34 100644 --- a/src/fgai/baseline.py +++ b/src/fgai/baseline.py @@ -11,6 +11,7 @@ from statistics import mean, pstdev from .logs import THREAT_ACTIONS, is_utm_event from .models import LogEvent from .entities import profile_entity +from .detectors import DETECTOR_MINIMUMS, event_detector_categories def _number(value: str | None) -> int: @@ -78,6 +79,15 @@ class BaselineStore: events integer not null, numeric_sum real not null, numeric_sum_squares real not null, primary key (stream_id, entity, field, weekday, hour, bucket_start) ); + create table if not exists profile_detector_buckets ( + stream_id text not null, entity text not null, detector text not null, bucket_start integer not null, + events integer not null, primary key (stream_id, entity, detector, bucket_start) + ); + create table if not exists profile_detector_temporal_buckets ( + stream_id text not null, entity text not null, detector text not null, + weekday integer not null, hour integer not null, bucket_start integer not null, + events integer not null, primary key (stream_id, entity, detector, weekday, hour, bucket_start) + ); """ ) @@ -124,6 +134,8 @@ class BaselineStore: observed_at = observed_at or int(time.time()) pending: dict[tuple[str, str, str, int], list[float]] = defaultdict(lambda: [0, 0.0, 0.0]) temporal_pending: dict[tuple[str, str, str, int, int, int], list[float]] = defaultdict(lambda: [0, 0.0, 0.0]) + detector_pending: Counter[tuple[str, str, str, int]] = Counter() + detector_temporal_pending: Counter[tuple[str, str, str, int, int, int]] = Counter() pending_values: Counter[tuple[str, str, str, str]] = Counter() with self._connect() as connection: for event in events: @@ -140,6 +152,9 @@ class BaselineStore: timestamp = _event_epoch(event, observed_at) bucket = timestamp - (timestamp % self.bucket_seconds) moment = datetime.fromtimestamp(timestamp, tz=timezone.utc) + for detector in event_detector_categories(event): + detector_pending[(stream_id, entity, detector, bucket)] += 1 + detector_temporal_pending[(stream_id, entity, detector, moment.weekday(), moment.hour, bucket)] += 1 fields = [*getattr(profile, "categorical_fields", ()), *getattr(profile, "numeric_fields", ())] numeric = {str(field).lower() for field in getattr(profile, "numeric_fields", ())} for field in fields: @@ -165,11 +180,18 @@ class BaselineStore: for key, count in pending_values.items(): connection.execute("""insert into profile_values values (?, ?, ?, ?, ?) on conflict(stream_id, entity, field, value) do update set seen_count=seen_count+excluded.seen_count""", (*key, count)) + for (stream_id, entity, detector, bucket), count in detector_pending.items(): + connection.execute("""insert into profile_detector_buckets values (?, ?, ?, ?, ?) + on conflict(stream_id, entity, detector, bucket_start) do update set events=events+excluded.events""", (stream_id, entity, detector, bucket, count)) + for (stream_id, entity, detector, weekday, hour, bucket), count in detector_temporal_pending.items(): + connection.execute("""insert into profile_detector_temporal_buckets values (?, ?, ?, ?, ?, ?, ?) + on conflict(stream_id, entity, detector, weekday, hour, bucket_start) do update set events=events+excluded.events""", (stream_id, entity, detector, weekday, hour, bucket, count)) return len(pending) def profile_deviations(self, events: list[LogEvent], profiles: dict[str, object]) -> dict[str, list[dict[str, object]]]: current: dict[tuple[str, str, str], list[float]] = defaultdict(lambda: [0, 0.0]) entity_events: dict[tuple[str, str], list[LogEvent]] = defaultdict(list) + detector_current: Counter[tuple[str, str, str]] = Counter() for event in events: profile = profiles.get(event.fields.get("fgai_stream_id", "")) if not profile: @@ -178,6 +200,8 @@ class BaselineStore: if not entity: continue entity_events[(event.fields.get("fgai_stream_id", ""), entity)].append(event) + for detector in event_detector_categories(event): + detector_current[(event.fields.get("fgai_stream_id", ""), entity, detector)] += 1 numeric = {str(field).lower() for field in getattr(profile, "numeric_fields", ())} for field in [*getattr(profile, "categorical_fields", ()), *getattr(profile, "numeric_fields", ())]: key = (event.fields.get("fgai_stream_id", ""), entity, str(field).lower()) @@ -239,6 +263,36 @@ class BaselineStore: score = min(30, (15 if confidence == "high" else 12 if confidence == "medium" else 8) + int(z_score)) samples = [{"timestamp": event.fields.get("eventtime", event.fields.get("timestamp", "")), "source": event.src_ip or event.fields.get("source", ""), "destination": event.dst_ip or "", "action": event.action, "severity": event.severity, "service": event.fields.get("service", ""), "value": "", "message": event.fields.get("message", event.fields.get("msg", ""))[:240]} for event in matching[:5]] output[entity].append({"detector": "event_rate_burst", "field": "event_rate", "stream_id": stream, "score": score, "confidence": confidence, "baseline_samples": len(rows), "baseline_scope": baseline_scope, "reason": f"event rate burst above its {baseline_scope} baseline (z={z_score:.1f})", "current": current_value, "baseline": round(mean(history), 2), "sample_values": [], "sample_events": samples}) + + for (stream, entity, detector), current_value in detector_current.items(): + profile = profiles.get(stream) + settings = getattr(profile, "detectors", {}).get(detector, {}) if profile else {} + if not settings.get("enabled", True): + continue + minimum = int(settings.get("minimum", DETECTOR_MINIMUMS[detector])) + z_threshold = float(settings.get("z_threshold", 3.0)) + if current_value < minimum: + continue + matching = [event for event in entity_events[(stream, entity)] if detector in event_detector_categories(event)] + current_timestamp = _event_epoch(matching[-1], int(time.time())) + moment = datetime.fromtimestamp(current_timestamp, tz=timezone.utc) + rows = connection.execute("select events from profile_detector_temporal_buckets where stream_id=? and entity=? and detector=? and weekday=? and hour=? order by bucket_start desc limit 25", (stream, entity, detector, moment.weekday(), moment.hour)).fetchall() + temporal = True + baseline_scope = "same weekday/hour" + if len(rows) < 12: + rows = connection.execute("select events from profile_detector_buckets where stream_id=? and entity=? and detector=? order by bucket_start desc limit 25", (stream, entity, detector)).fetchall() + temporal = False + baseline_scope = "all observed periods" + if len(rows) < 12: + continue + history = [row[0] for row in rows] + z_score = (current_value - mean(history)) / (pstdev(history) or 1.0) + if z_score < z_threshold: + continue + confidence = _baseline_confidence(len(rows), temporal=temporal) + score = min(35, (18 if detector == "auth_failure" else 15 if detector == "deny_action" else 12) + int(z_score)) + samples = [{"timestamp": event.fields.get("eventtime", event.fields.get("timestamp", "")), "source": event.src_ip or event.fields.get("source", ""), "destination": event.dst_ip or event.fields.get("query_domain", ""), "action": event.action, "severity": event.severity, "service": event.fields.get("service", event.fields.get("query_type", "")), "value": detector, "message": event.fields.get("message", event.fields.get("msg", ""))[:240]} for event in matching[:5]] + output[entity].append({"detector": f"{detector}_burst", "field": detector, "stream_id": stream, "score": score, "confidence": confidence, "baseline_samples": len(rows), "baseline_scope": baseline_scope, "reason": f"{detector.replace('_', ' ')} burst above its {baseline_scope} baseline (z={z_score:.1f}, minimum={minimum})", "current": current_value, "baseline": round(mean(history), 2), "sample_values": [detector], "sample_events": samples}) # Detect selected categorical values that have not appeared for this entity in prior data. for event in events: profile = profiles.get(event.fields.get("fgai_stream_id", "")) diff --git a/src/fgai/cli.py b/src/fgai/cli.py index fdd8d40..cdaaf19 100644 --- a/src/fgai/cli.py +++ b/src/fgai/cli.py @@ -15,6 +15,9 @@ from .recommendations import build_recommendations from .syslog_server import listen_udp_syslog from .monitor import monitor_loop from .threat_intel import enrich_ips, is_public_ip +from .config import ConfigStore +from .replay import replay_events +from .stream_profiles import parse_profiles def _print_json(data: object) -> None: @@ -215,6 +218,15 @@ def run_dashboard(args: argparse.Namespace) -> int: return 0 +def replay_history(args: argparse.Namespace) -> int: + config = ConfigStore(args.config_file).read() + 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) + result = replay_events(read_events(args.logs), profiles, stream_id=args.stream_id, stream_name=stream_name, bucket_seconds=args.bucket_seconds) + _print_json(result) + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Local FortiGate AI/ML inspection tool") subparsers = parser.add_subparsers(required=True) @@ -305,6 +317,13 @@ def build_parser() -> argparse.ArgumentParser: dashboard.add_argument("--config-file", default="state/fgai-config.json", help="Local runtime configuration JSON") dashboard.set_defaults(func=run_dashboard) + replay = subparsers.add_parser("replay", help="Replay a historical log export against temporary baselines") + replay.add_argument("--logs", required=True, help="Historic JSONL or key/value log export") + 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("--bucket-seconds", type=int, default=300, help="Replay baseline bucket size") + replay.set_defaults(func=replay_history) + return parser diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index aa270c6..3039b51 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -80,7 +80,7 @@ HTML = """

Events and Anomalies

Baseline and Stream Health

Correlation Map

AI Assessment

LLM assessment disabled.

Investigation Incidents

Anomalies

Recommendations

Field Baseline Deviations

Related Activity Across Sources

Block Candidates

Threat Intelligence

Policy Findings

Diagnostics

-

Runtime Configuration

+

Runtime Configuration