sequence detection

This commit is contained in:
larssand
2026-06-25 22:05:04 +02:00
parent 9da1aedf52
commit 0e41e00ebc
5 changed files with 179 additions and 1 deletions

View File

@@ -88,6 +88,12 @@ baselines for authentication failures, DNS queries, and deny/block actions when
those events are present. These are evaluated per configured entity, so a Windows 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. account, DNS client, or firewall source is compared to its own history.
SignalScope also detects ordered behavior sequences across any streams that share
an entity. The built-in sequence is category-based, not source-specific:
`dns_query -> network_connection -> auth_failure`. Those categories can come from
AdGuard, Windows DNS, a proxy, firewall, VPN, endpoint, or any other Graylog
stream as long as the fields normalize into the same generic event model.
Stream profiles can also carry `field_weights` to tune scoring without changing Stream profiles can also carry `field_weights` to tune scoring without changing
the baseline itself. Weights are multipliers from `0` to `5` and can target a the baseline itself. Weights are multipliers from `0` to `5` and can target a
field, a detector, or a field+detector pair: field, a detector, or a field+detector pair:

View File

@@ -27,7 +27,7 @@ Goal: make findings more accurate before adding more integrations.
- [x] Add rare-value detection with a minimum historical observation threshold. - [x] Add rare-value detection with a minimum historical observation threshold.
- [x] 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.
- [x] Add configurable per-field detector weights. - [x] Add configurable per-field detector weights.
- [ ] Add sequence detection, for example DNS lookup -> outbound connection -> authentication event. - [x] 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.
- [x] 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.

View File

@@ -20,6 +20,7 @@ from .logs import local_in_failures, read_events, summarize_events, top_field_va
from .mitigation import parse_allowlist, suggest_block_candidates from .mitigation import parse_allowlist, suggest_block_candidates
from .policies import audit_policies, read_policies from .policies import audit_policies, read_policies
from .recommendations import build_recommendations from .recommendations import build_recommendations
from .sequences import detect_sequences
from .threat_intel import ThreatIntelClient, enrich_ips, is_public_ip from .threat_intel import ThreatIntelClient, enrich_ips, is_public_ip
from .stream_profiles import parse_profiles from .stream_profiles import parse_profiles
@@ -104,6 +105,9 @@ def build_status(
baseline = BaselineStore(baseline_path) if baseline_path else None baseline = BaselineStore(baseline_path) if baseline_path else None
profiles = baseline.profiles({event.src_ip for event in events if event.src_ip}) if baseline else {} profiles = baseline.profiles({event.src_ip for event in events if event.src_ip}) if baseline else {}
field_deviations = baseline.profile_deviations(events, stream_profiles) if baseline else {} field_deviations = baseline.profile_deviations(events, stream_profiles) if baseline else {}
sequence_findings = detect_sequences(events)
for entity, findings in sequence_findings.items():
field_deviations.setdefault(entity, []).extend(findings)
for deviations in field_deviations.values(): for deviations in field_deviations.values():
for deviation in deviations: for deviation in deviations:
stream_id = str(deviation.get("stream_id", "")) stream_id = str(deviation.get("stream_id", ""))
@@ -194,6 +198,7 @@ def build_status(
}, },
"event_context": build_event_context(events), "event_context": build_event_context(events),
"field_deviations": field_deviations, "field_deviations": field_deviations,
"sequence_findings": sequence_findings,
"feedback": feedback, "feedback": feedback,
"cross_source_correlations": correlations, "cross_source_correlations": correlations,
"incidents": build_incidents(anomalies, field_deviations, correlations), "incidents": build_incidents(anomalies, field_deviations, correlations),

123
src/fgai/sequences.py Normal file
View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from .detectors import event_detector_categories
from .entities import event_entities
from .logs import THREAT_ACTIONS
from .models import LogEvent
DEFAULT_SEQUENCE_PATTERNS = {
"dns_network_auth_sequence": ("dns_query", "network_connection", "auth_failure"),
}
def _timestamp(event: LogEvent, fallback: int) -> int:
value = event.fields.get("eventtime", event.fields.get("timestamp", ""))
if value:
try:
parsed = float(value)
while parsed > 10_000_000_000:
parsed /= 1_000
return int(parsed)
except ValueError:
try:
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
except ValueError:
pass
date = event.fields.get("date")
clock = event.fields.get("time")
if date and clock:
try:
return int(datetime.fromisoformat(f"{date}T{clock}").timestamp())
except ValueError:
pass
return fallback
def _is_network_event(event: LogEvent) -> bool:
if event.fields.get("dstip") or event.fields.get("destination_ip") or event.fields.get("dst_ip"):
return True
if event.fields.get("dstport") or event.fields.get("destination_port") or event.fields.get("service"):
return True
return event.action in {"accept", "pass", "allowed", "allow", "close", "client-rst", "server-rst"} | THREAT_ACTIONS
def _sample(event: LogEvent, value: str) -> dict[str, str]:
return {
"timestamp": event.fields.get("eventtime", event.fields.get("timestamp", "")),
"stream": event.fields.get("fgai_stream", event.fields.get("fgai_stream_id", "")),
"source": event.src_ip or event.fields.get("source", ""),
"destination": event.dst_ip or event.fields.get("query_domain", event.fields.get("qh", "")),
"action": event.action,
"severity": event.severity,
"service": event.fields.get("service", event.fields.get("query_type", "")),
"value": value,
"message": event.fields.get("message", event.fields.get("msg", ""))[:240],
}
def detect_sequences(events: list[LogEvent], *, window_seconds: int = 900, limit: int = 50, patterns: dict[str, tuple[str, ...]] | None = None) -> dict[str, list[dict[str, object]]]:
"""Detect ordered multi-event behavior for any entity shared across streams."""
patterns = patterns or DEFAULT_SEQUENCE_PATTERNS
by_entity: dict[str, list[tuple[int, LogEvent, set[str]]]] = defaultdict(list)
for index, event in enumerate(events):
categories = set(event_detector_categories(event))
if _is_network_event(event):
categories.add("network_connection")
if not categories:
continue
timestamp = _timestamp(event, index)
for identity in event_entities(event):
by_entity[identity["entity"]].append((timestamp, event, categories))
findings: dict[str, list[dict[str, object]]] = defaultdict(list)
for entity, items in by_entity.items():
ordered = sorted(items, key=lambda item: item[0])
for detector, sequence in patterns.items():
match: list[tuple[int, LogEvent, str]] = []
start_at = 0
for category in sequence:
found = next(
(
(timestamp, event, category)
for timestamp, event, categories in ordered
if timestamp >= start_at and (not match or timestamp <= match[0][0] + window_seconds) and category in categories
),
None,
)
if not found:
match = []
break
match.append(found)
start_at = found[0]
if len(match) != len(sequence):
continue
duration = max(0, match[-1][0] - match[0][0])
streams = sorted({event.fields.get("fgai_stream", "") for _, event, _ in match} - {""})
score = 28 if len(streams) >= 2 else 20
findings[entity].append({
"detector": detector,
"field": "sequence",
"stream_id": "multi_stream",
"stream_name": "Multi-stream",
"stream_title": "Multi-stream",
"score": score,
"base_score": score,
"weight": 1.0,
"confidence": "medium" if len(streams) >= 2 else "low",
"baseline_samples": 0,
"baseline_scope": f"{window_seconds}s sequence window",
"reason": f"ordered {' -> '.join(sequence)} sequence observed in {duration}s",
"current": len(sequence),
"baseline": 0,
"value": detector,
"sample_values": streams,
"sample_events": [_sample(event, category) for _, event, category in match],
})
if sum(len(values) for values in findings.values()) >= limit:
return findings
break
return findings

44
tests/test_sequences.py Normal file
View File

@@ -0,0 +1,44 @@
import unittest
from fgai.logs import parse_log_line
from fgai.sequences import detect_sequences
class SequenceTests(unittest.TestCase):
def test_detects_generic_dns_network_auth_sequence(self):
events = [
parse_log_line("timestamp=2026-06-25T10:00:00Z fgai_stream=Resolver srcip=10.0.0.5 query_domain=example.test"),
parse_log_line("timestamp=2026-06-25T10:02:00Z fgai_stream=Proxy srcip=10.0.0.5 dstip=203.0.113.10 dstport=443 action=accept"),
parse_log_line("timestamp=2026-06-25T10:05:00Z fgai_stream=Identity srcip=10.0.0.5 action=failed eventid=4625"),
]
result = detect_sequences(events)
self.assertIn("10.0.0.5", result)
finding = result["10.0.0.5"][0]
self.assertEqual(finding["detector"], "dns_network_auth_sequence")
self.assertEqual(finding["sample_values"], ["Identity", "Proxy", "Resolver"])
self.assertEqual([item["value"] for item in finding["sample_events"]], ["dns_query", "network_connection", "auth_failure"])
def test_does_not_match_sequence_outside_window(self):
events = [
parse_log_line("timestamp=2026-06-25T10:00:00Z fgai_stream=Resolver srcip=10.0.0.5 query_domain=example.test"),
parse_log_line("timestamp=2026-06-25T10:02:00Z fgai_stream=Proxy srcip=10.0.0.5 dstip=203.0.113.10 dstport=443 action=accept"),
parse_log_line("timestamp=2026-06-25T11:00:00Z fgai_stream=Identity srcip=10.0.0.5 action=failed eventid=4625"),
]
self.assertEqual(detect_sequences(events, window_seconds=900), {})
def test_supports_custom_generic_patterns(self):
events = [
parse_log_line("timestamp=2026-06-25T10:00:00Z fgai_stream=Proxy srcip=10.0.0.5 dstip=203.0.113.10 action=accept"),
parse_log_line("timestamp=2026-06-25T10:01:00Z fgai_stream=Firewall srcip=10.0.0.5 action=deny"),
]
result = detect_sequences(events, patterns={"connection_then_deny": ("network_connection", "deny_action")})
self.assertEqual(result["10.0.0.5"][0]["detector"], "connection_then_deny")
if __name__ == "__main__":
unittest.main()