Implemented the baseline/noise and SQLite growth improvements.
This commit is contained in:
23
README.md
23
README.md
@@ -237,6 +237,29 @@ The continuous monitor also stores a local SQLite behavior baseline at
|
|||||||
five-minute windows. Historical rate and hitcount-rate deviations then contribute
|
five-minute windows. Historical rate and hitcount-rate deviations then contribute
|
||||||
to its anomaly score. Set `FGAI_BASELINE_DB` to use another location.
|
to its anomaly score. Set `FGAI_BASELINE_DB` to use another location.
|
||||||
|
|
||||||
|
SignalScope prunes old baseline buckets during each monitor cycle. The defaults
|
||||||
|
keep 14 days of buckets and dedupe history, prune stale one-off categorical
|
||||||
|
values after 7 days, and cap high-cardinality values per stream/entity/field.
|
||||||
|
Tune these in the dashboard or in `state/fgai-config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"baseline_retention_days": 14,
|
||||||
|
"baseline_value_retention_days": 7,
|
||||||
|
"baseline_max_values_per_field": 2000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If an existing baseline database has already grown large, stop the monitor and
|
||||||
|
run a manual prune plus SQLite compaction:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
signalscope baseline-maintenance --baseline-db state/fgai-baseline.sqlite3 --retention-days 14 --value-retention-days 7 --max-values-per-field 2000 --vacuum
|
||||||
|
```
|
||||||
|
|
||||||
|
`VACUUM` can take time on a large database and should not be run while the
|
||||||
|
monitor is actively writing.
|
||||||
|
|
||||||
Analyze local logs:
|
Analyze local logs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ Goal: make findings more accurate before adding more integrations.
|
|||||||
- [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.
|
||||||
- [ ] Add dashboard controls for launching safe replay jobs and viewing detector deltas.
|
- [ ] Add dashboard controls for launching safe replay jobs and viewing detector deltas.
|
||||||
|
- [ ] Add baseline confidence tooling: per detector learning state, expected false-positive rate, and why a deviation crossed threshold.
|
||||||
|
- [ ] Add baseline maintenance tooling in the dashboard for retention, high-cardinality fields, and database compaction status.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ Goal: make one incident answer what happened, to whom, and across which sources.
|
|||||||
- [x] Persist incident state and analyst notes separately from transient detection output.
|
- [x] Persist incident state and analyst notes separately from transient detection output.
|
||||||
- [x] Add direct Graylog query links or query details for each timeline event.
|
- [x] Add direct Graylog query links or query details for each timeline event.
|
||||||
- [x] Add investigation export as JSON and Markdown report.
|
- [x] Add investigation export as JSON and Markdown report.
|
||||||
|
- [ ] Add guided investigation tools that compare a selected incident against its baseline, related entities, and similar prior outcomes.
|
||||||
|
|
||||||
Acceptance: an analyst can open an incident, see an ordered multi-stream timeline, review evidence, and record an outcome without losing it after the next monitor poll.
|
Acceptance: an analyst can open an incident, see an ordered multi-stream timeline, review evidence, and record an outcome without losing it after the next monitor poll.
|
||||||
|
|
||||||
@@ -79,7 +82,8 @@ Goal: run reliably in a monitored environment.
|
|||||||
|
|
||||||
- [ ] Add systemd unit files for monitor, dashboard, and optional local syslog listener.
|
- [ ] Add systemd unit files for monitor, dashboard, and optional local syslog listener.
|
||||||
- [ ] Add health and readiness endpoints with last successful Graylog fetch time.
|
- [ ] Add health and readiness endpoints with last successful Graylog fetch time.
|
||||||
- [ ] Add structured application logs and configurable retention for status/history/baseline data.
|
- [x] Add configurable retention for baseline buckets and high-cardinality field values.
|
||||||
|
- [ ] Add structured application logs and configurable retention for status/history data.
|
||||||
- [ ] Add backup and migration procedure for SQLite state.
|
- [ ] Add backup and migration procedure for SQLite state.
|
||||||
- [ ] Add Checkmk local-check output in addition to Prometheus metrics.
|
- [ ] Add Checkmk local-check output in addition to Prometheus metrics.
|
||||||
- [ ] Add authentication/reverse-proxy guidance before exposing the dashboard beyond loopback.
|
- [ ] Add authentication/reverse-proxy guidance before exposing the dashboard beyond loopback.
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ from .detectors import DETECTOR_MINIMUMS, event_detector_categories
|
|||||||
from .normalization import canonical_value
|
from .normalization import canonical_value
|
||||||
from .query_details import event_query_details
|
from .query_details import event_query_details
|
||||||
|
|
||||||
|
DEFAULT_RETENTION_DAYS = 14
|
||||||
|
DEFAULT_VALUE_RETENTION_DAYS = 7
|
||||||
|
DEFAULT_MAX_VALUES_PER_FIELD = 2000
|
||||||
|
MIN_REPORTED_DEVIATION_SCORE = 15
|
||||||
|
MAX_RARE_VALUES_PER_ENTITY = 5
|
||||||
|
|
||||||
|
|
||||||
def _number(value: str | None) -> int:
|
def _number(value: str | None) -> int:
|
||||||
try:
|
try:
|
||||||
@@ -128,6 +134,27 @@ class BaselineStore:
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
self._migrate(connection)
|
||||||
|
|
||||||
|
def _migrate(self, connection: sqlite3.Connection) -> None:
|
||||||
|
columns = {row[1] for row in connection.execute("pragma table_info(seen_events)").fetchall()}
|
||||||
|
if "first_seen" not in columns:
|
||||||
|
connection.execute("alter table seen_events add column first_seen integer not null default 0")
|
||||||
|
columns = {row[1] for row in connection.execute("pragma table_info(profile_seen_events)").fetchall()}
|
||||||
|
if "first_seen" not in columns:
|
||||||
|
connection.execute("alter table profile_seen_events add column first_seen integer not null default 0")
|
||||||
|
columns = {row[1] for row in connection.execute("pragma table_info(profile_values)").fetchall()}
|
||||||
|
if "last_seen" not in columns:
|
||||||
|
connection.execute("alter table profile_values add column last_seen integer not null default 0")
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
create index if not exists idx_profile_buckets_lookup on profile_buckets(stream_id, entity, field, bucket_start);
|
||||||
|
create index if not exists idx_profile_temporal_lookup on profile_temporal_buckets(stream_id, entity, field, weekday, hour, bucket_start);
|
||||||
|
create index if not exists idx_profile_detector_lookup on profile_detector_buckets(stream_id, entity, detector, bucket_start);
|
||||||
|
create index if not exists idx_profile_detector_temporal_lookup on profile_detector_temporal_buckets(stream_id, entity, detector, weekday, hour, bucket_start);
|
||||||
|
create index if not exists idx_profile_values_lookup on profile_values(stream_id, entity, field, seen_count);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
return sqlite3.connect(self.path)
|
return sqlite3.connect(self.path)
|
||||||
@@ -141,7 +168,7 @@ class BaselineStore:
|
|||||||
if not event.src_ip:
|
if not event.src_ip:
|
||||||
continue
|
continue
|
||||||
fingerprint = hashlib.sha256(event.raw.encode("utf-8", errors="replace")).hexdigest()
|
fingerprint = hashlib.sha256(event.raw.encode("utf-8", errors="replace")).hexdigest()
|
||||||
if connection.execute("insert or ignore into seen_events values (?)", (fingerprint,)).rowcount != 1:
|
if connection.execute("insert or ignore into seen_events(fingerprint, first_seen) values (?, ?)", (fingerprint, observed_at)).rowcount != 1:
|
||||||
continue
|
continue
|
||||||
bucket = observed_at - (observed_at % self.bucket_seconds)
|
bucket = observed_at - (observed_at % self.bucket_seconds)
|
||||||
values = pending[(event.src_ip, bucket)]
|
values = pending[(event.src_ip, bucket)]
|
||||||
@@ -182,7 +209,7 @@ class BaselineStore:
|
|||||||
if not profile:
|
if not profile:
|
||||||
continue
|
continue
|
||||||
fingerprint = hashlib.sha256(f"{stream_id}|{event.raw}".encode("utf-8", errors="replace")).hexdigest()
|
fingerprint = hashlib.sha256(f"{stream_id}|{event.raw}".encode("utf-8", errors="replace")).hexdigest()
|
||||||
if connection.execute("insert or ignore into profile_seen_events values (?)", (fingerprint,)).rowcount != 1:
|
if connection.execute("insert or ignore into profile_seen_events(fingerprint, first_seen) values (?, ?)", (fingerprint, observed_at)).rowcount != 1:
|
||||||
continue
|
continue
|
||||||
entities = profile_entities(event, profile)
|
entities = profile_entities(event, profile)
|
||||||
if not entities:
|
if not entities:
|
||||||
@@ -217,8 +244,8 @@ class BaselineStore:
|
|||||||
connection.execute("""insert into profile_temporal_buckets values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
connection.execute("""insert into profile_temporal_buckets values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
on conflict(stream_id, entity, field, weekday, hour, bucket_start) do update set events=events+excluded.events,numeric_sum=numeric_sum+excluded.numeric_sum,numeric_sum_squares=numeric_sum_squares+excluded.numeric_sum_squares""", (stream_id, entity, field, weekday, hour, bucket, *values))
|
on conflict(stream_id, entity, field, weekday, hour, bucket_start) do update set events=events+excluded.events,numeric_sum=numeric_sum+excluded.numeric_sum,numeric_sum_squares=numeric_sum_squares+excluded.numeric_sum_squares""", (stream_id, entity, field, weekday, hour, bucket, *values))
|
||||||
for key, count in pending_values.items():
|
for key, count in pending_values.items():
|
||||||
connection.execute("""insert into profile_values values (?, ?, ?, ?, ?)
|
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))
|
on conflict(stream_id, entity, field, value) do update set seen_count=seen_count+excluded.seen_count,last_seen=excluded.last_seen""", (*key, count, observed_at))
|
||||||
for (stream_id, entity, detector, bucket), count in detector_pending.items():
|
for (stream_id, entity, detector, bucket), count in detector_pending.items():
|
||||||
connection.execute("""insert into profile_detector_buckets values (?, ?, ?, ?, ?)
|
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))
|
on conflict(stream_id, entity, detector, bucket_start) do update set events=events+excluded.events""", (stream_id, entity, detector, bucket, count))
|
||||||
@@ -276,6 +303,7 @@ class BaselineStore:
|
|||||||
confidence = _baseline_confidence(len(rows), temporal=temporal)
|
confidence = _baseline_confidence(len(rows), temporal=temporal)
|
||||||
base_score = 18 if confidence == "high" else 15 if confidence == "medium" else 10
|
base_score = 18 if confidence == "high" else 15 if confidence == "medium" else 10
|
||||||
score, weight = _weighted_score(base_score, profile, field, "numeric_baseline")
|
score, weight = _weighted_score(base_score, profile, field, "numeric_baseline")
|
||||||
|
if score >= MIN_REPORTED_DEVIATION_SCORE:
|
||||||
output[entity].append({"detector": "numeric_baseline", "field": field, "stream_id": stream, "score": score, "base_score": base_score, "weight": weight, "confidence": confidence, "baseline_samples": len(rows), "baseline_scope": baseline_scope, "reason": reason, "current": round(current_value, 2), "baseline": round(mean(history), 2), "sample_values": sample_values, "sample_events": evidence_events})
|
output[entity].append({"detector": "numeric_baseline", "field": field, "stream_id": stream, "score": score, "base_score": base_score, "weight": weight, "confidence": confidence, "baseline_samples": len(rows), "baseline_scope": baseline_scope, "reason": reason, "current": round(current_value, 2), "baseline": round(mean(history), 2), "sample_values": sample_values, "sample_events": evidence_events})
|
||||||
|
|
||||||
# Event-rate burst is calculated once per stream/entity, rather than once per selected field.
|
# Event-rate burst is calculated once per stream/entity, rather than once per selected field.
|
||||||
@@ -305,6 +333,7 @@ class BaselineStore:
|
|||||||
base_score = min(30, (15 if confidence == "high" else 12 if confidence == "medium" else 8) + int(z_score))
|
base_score = min(30, (15 if confidence == "high" else 12 if confidence == "medium" else 8) + int(z_score))
|
||||||
score, weight = _weighted_score(base_score, profile, "event_rate", "event_rate_burst")
|
score, weight = _weighted_score(base_score, profile, "event_rate", "event_rate_burst")
|
||||||
samples = [_sample_event(event) for event in matching[:5]]
|
samples = [_sample_event(event) for event in matching[:5]]
|
||||||
|
if score >= MIN_REPORTED_DEVIATION_SCORE:
|
||||||
output[entity].append({"detector": "event_rate_burst", "field": "event_rate", "stream_id": stream, "score": score, "base_score": base_score, "weight": weight, "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})
|
output[entity].append({"detector": "event_rate_burst", "field": "event_rate", "stream_id": stream, "score": score, "base_score": base_score, "weight": weight, "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():
|
for (stream, entity, detector), current_value in detector_current.items():
|
||||||
@@ -336,8 +365,10 @@ class BaselineStore:
|
|||||||
base_score = min(35, (18 if detector == "auth_failure" else 15 if detector == "deny_action" else 12) + int(z_score))
|
base_score = min(35, (18 if detector == "auth_failure" else 15 if detector == "deny_action" else 12) + int(z_score))
|
||||||
score, weight = _weighted_score(base_score, profile, detector, f"{detector}_burst")
|
score, weight = _weighted_score(base_score, profile, detector, f"{detector}_burst")
|
||||||
samples = [_sample_event(event, detector) for event in matching[:5]]
|
samples = [_sample_event(event, detector) for event in matching[:5]]
|
||||||
|
if score >= MIN_REPORTED_DEVIATION_SCORE:
|
||||||
output[entity].append({"detector": f"{detector}_burst", "field": detector, "stream_id": stream, "score": score, "base_score": base_score, "weight": weight, "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})
|
output[entity].append({"detector": f"{detector}_burst", "field": detector, "stream_id": stream, "score": score, "base_score": base_score, "weight": weight, "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.
|
# Detect selected categorical values that have not appeared for this entity in prior data.
|
||||||
|
rare_counts: Counter[tuple[str, str]] = Counter()
|
||||||
for event in events:
|
for event in events:
|
||||||
profile = profiles.get(event.fields.get("fgai_stream_id", ""))
|
profile = profiles.get(event.fields.get("fgai_stream_id", ""))
|
||||||
if not profile:
|
if not profile:
|
||||||
@@ -355,13 +386,69 @@ class BaselineStore:
|
|||||||
known = connection.execute("select seen_count from profile_values where stream_id=? and entity=? and field=? and value=?", (stream, entity, field, value)).fetchone()
|
known = connection.execute("select seen_count from profile_values where stream_id=? and entity=? and field=? and value=?", (stream, entity, field, value)).fetchone()
|
||||||
known_total = connection.execute("select coalesce(sum(seen_count), 0) from profile_values where stream_id=? and entity=? and field=?", (stream, entity, field)).fetchone()[0]
|
known_total = connection.execute("select coalesce(sum(seen_count), 0) from profile_values where stream_id=? and entity=? and field=?", (stream, entity, field)).fetchone()[0]
|
||||||
if known is None and known_total >= 30:
|
if known is None and known_total >= 30:
|
||||||
|
if rare_counts[(stream, entity)] >= MAX_RARE_VALUES_PER_ENTITY:
|
||||||
|
continue
|
||||||
samples = [item for item in events if item.fields.get("fgai_stream_id") == stream and entity in profile_entities(item, profile) and item.fields.get(field) == value]
|
samples = [item for item in events if item.fields.get("fgai_stream_id") == stream and entity in profile_entities(item, profile) and item.fields.get(field) == value]
|
||||||
score, weight = _weighted_score(12, profile, field, "rare_value")
|
base_score = 18
|
||||||
evidence = {"detector": "rare_value", "field": field, "stream_id": stream, "score": score, "base_score": 12, "weight": weight, "confidence": "medium", "baseline_samples": int(known_total), "baseline_scope": "known field values", "reason": f"new {field} value for this entity", "value": value, "sample_values": [value], "sample_events": [_sample_event(item, value) for item in samples[:5]]}
|
score, weight = _weighted_score(base_score, profile, field, "rare_value")
|
||||||
|
if score < MIN_REPORTED_DEVIATION_SCORE:
|
||||||
|
continue
|
||||||
|
evidence = {"detector": "rare_value", "field": field, "stream_id": stream, "score": score, "base_score": base_score, "weight": weight, "confidence": "medium", "baseline_samples": int(known_total), "baseline_scope": "known field values", "reason": f"new {field} value for this entity", "value": value, "sample_values": [value], "sample_events": [_sample_event(item, value) for item in samples[:5]]}
|
||||||
if evidence not in output[entity]:
|
if evidence not in output[entity]:
|
||||||
output[entity].append(evidence)
|
output[entity].append(evidence)
|
||||||
|
rare_counts[(stream, entity)] += 1
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def maintenance(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
retention_days: int = DEFAULT_RETENTION_DAYS,
|
||||||
|
value_retention_days: int = DEFAULT_VALUE_RETENTION_DAYS,
|
||||||
|
max_values_per_field: int = DEFAULT_MAX_VALUES_PER_FIELD,
|
||||||
|
vacuum: bool = False,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
now = int(time.time())
|
||||||
|
bucket_cutoff = now - max(1, retention_days) * 86400
|
||||||
|
value_cutoff = now - max(1, value_retention_days) * 86400
|
||||||
|
deleted: dict[str, int] = {}
|
||||||
|
with self._connect() as connection:
|
||||||
|
for table in (
|
||||||
|
"source_buckets",
|
||||||
|
"profile_buckets",
|
||||||
|
"profile_temporal_buckets",
|
||||||
|
"profile_detector_buckets",
|
||||||
|
"profile_detector_temporal_buckets",
|
||||||
|
):
|
||||||
|
deleted[table] = connection.execute(f"delete from {table} where bucket_start < ?", (bucket_cutoff,)).rowcount
|
||||||
|
deleted["seen_events"] = connection.execute("delete from seen_events where first_seen > 0 and first_seen < ?", (bucket_cutoff,)).rowcount
|
||||||
|
deleted["profile_seen_events"] = connection.execute("delete from profile_seen_events where first_seen > 0 and first_seen < ?", (bucket_cutoff,)).rowcount
|
||||||
|
deleted["profile_values_stale_low_count"] = connection.execute("delete from profile_values where last_seen > 0 and last_seen < ? and seen_count <= 1", (value_cutoff,)).rowcount
|
||||||
|
if max_values_per_field > 0:
|
||||||
|
rows = connection.execute("select stream_id, entity, field, count(*) from profile_values group by stream_id, entity, field having count(*) > ?", (max_values_per_field,)).fetchall()
|
||||||
|
trimmed = 0
|
||||||
|
for stream_id, entity, field, _count in rows:
|
||||||
|
keep = {
|
||||||
|
row[0]
|
||||||
|
for row in connection.execute(
|
||||||
|
"""select value from profile_values
|
||||||
|
where stream_id=? and entity=? and field=?
|
||||||
|
order by seen_count desc, last_seen desc limit ?""",
|
||||||
|
(stream_id, entity, field, max_values_per_field),
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
placeholders = ",".join("?" for _ in keep)
|
||||||
|
if keep:
|
||||||
|
trimmed += connection.execute(
|
||||||
|
f"delete from profile_values where stream_id=? and entity=? and field=? and value not in ({placeholders})",
|
||||||
|
(stream_id, entity, field, *keep),
|
||||||
|
).rowcount
|
||||||
|
deleted["profile_values_capped"] = trimmed
|
||||||
|
connection.execute("pragma optimize")
|
||||||
|
if vacuum:
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("vacuum")
|
||||||
|
return {"retention_days": retention_days, "value_retention_days": value_retention_days, "max_values_per_field": max_values_per_field, "vacuum": vacuum, "deleted": deleted, "size_bytes": self.path.stat().st_size if self.path.exists() else 0}
|
||||||
|
|
||||||
def profile_readiness(self, profiles: dict[str, object]) -> list[dict[str, object]]:
|
def profile_readiness(self, profiles: dict[str, object]) -> list[dict[str, object]]:
|
||||||
rows: list[dict[str, object]] = []
|
rows: list[dict[str, object]] = []
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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 .baseline import BaselineStore, DEFAULT_MAX_VALUES_PER_FIELD, DEFAULT_RETENTION_DAYS, DEFAULT_VALUE_RETENTION_DAYS
|
||||||
from .exports import investigation_report, investigation_report_markdown
|
from .exports import investigation_report, investigation_report_markdown
|
||||||
from .graylog_mcp import GraylogMcpClient
|
from .graylog_mcp import GraylogMcpClient
|
||||||
from .graylog_source import GraylogStreamSource
|
from .graylog_source import GraylogStreamSource
|
||||||
@@ -237,6 +238,17 @@ def export_investigation(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def baseline_maintenance(args: argparse.Namespace) -> int:
|
||||||
|
result = BaselineStore(args.baseline_db).maintenance(
|
||||||
|
retention_days=args.retention_days,
|
||||||
|
value_retention_days=args.value_retention_days,
|
||||||
|
max_values_per_field=args.max_values_per_field,
|
||||||
|
vacuum=args.vacuum,
|
||||||
|
)
|
||||||
|
_print_json(result)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _stream_name(config: dict[str, object], stream_id: str) -> str:
|
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)
|
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)
|
||||||
|
|
||||||
@@ -430,6 +442,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
export.add_argument("--output", default="", help="Write the report to this file instead of stdout")
|
export.add_argument("--output", default="", help="Write the report to this file instead of stdout")
|
||||||
export.set_defaults(func=export_investigation)
|
export.set_defaults(func=export_investigation)
|
||||||
|
|
||||||
|
maintenance = subparsers.add_parser("baseline-maintenance", help="Prune and optionally compact the local SQLite baseline")
|
||||||
|
maintenance.add_argument("--baseline-db", default="state/fgai-baseline.sqlite3", help="SQLite baseline database")
|
||||||
|
maintenance.add_argument("--retention-days", type=int, default=DEFAULT_RETENTION_DAYS, help="Keep bucket and dedupe history for this many days")
|
||||||
|
maintenance.add_argument("--value-retention-days", type=int, default=DEFAULT_VALUE_RETENTION_DAYS, help="Prune stale one-off categorical values after this many days")
|
||||||
|
maintenance.add_argument("--max-values-per-field", type=int, default=DEFAULT_MAX_VALUES_PER_FIELD, help="Keep at most this many categorical values per stream/entity/field")
|
||||||
|
maintenance.add_argument("--vacuum", action="store_true", help="Run SQLite VACUUM after pruning to return disk space")
|
||||||
|
maintenance.set_defaults(func=baseline_maintenance)
|
||||||
|
|
||||||
replay = subparsers.add_parser("replay", help="Replay a historical log export against temporary baselines")
|
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("--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("--config-file", default="state/fgai-config.json", help="Stream profile configuration")
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ DEFAULT_CONFIG: dict[str, object] = {
|
|||||||
"graylog_stream_profiles": [],
|
"graylog_stream_profiles": [],
|
||||||
"graylog_query": "*",
|
"graylog_query": "*",
|
||||||
"graylog_range_seconds": 3600,
|
"graylog_range_seconds": 3600,
|
||||||
|
"baseline_retention_days": 14,
|
||||||
|
"baseline_value_retention_days": 7,
|
||||||
|
"baseline_max_values_per_field": 2000,
|
||||||
"graylog_field_mapping": "",
|
"graylog_field_mapping": "",
|
||||||
"llm_enabled": False,
|
"llm_enabled": False,
|
||||||
"llm_model": "",
|
"llm_model": "",
|
||||||
@@ -49,9 +52,10 @@ class ConfigStore:
|
|||||||
current[key] = bool(value)
|
current[key] = bool(value)
|
||||||
elif key == "log_source" and value in {"local_syslog", "graylog_mcp"}:
|
elif key == "log_source" and value in {"local_syslog", "graylog_mcp"}:
|
||||||
current[key] = value
|
current[key] = value
|
||||||
elif key == "graylog_range_seconds":
|
elif key in {"graylog_range_seconds", "baseline_retention_days", "baseline_value_retention_days", "baseline_max_values_per_field"}:
|
||||||
try:
|
try:
|
||||||
current[key] = max(60, int(value))
|
minimum = 60 if key == "graylog_range_seconds" else 1
|
||||||
|
current[key] = max(minimum, int(value))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
elif key == "graylog_streams" and isinstance(value, list):
|
elif key == "graylog_streams" and isinstance(value, list):
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ HTML = """<!doctype html>
|
|||||||
<div data-view="overview" class="active"><section class="split"><div class="panel"><h2>Events and Anomalies</h2><canvas id="trendChart" class="chart"></canvas></div><div class="panel"><h2>Baseline and Stream Health</h2><div id="health"></div></div></section><section class="split"><div class="panel"><h2>Correlation Map</h2><canvas id="correlationGraph" class="graph"></canvas><div id="correlationGraphInfo" class="muted"></div></div><div class="panel"><h2>AI Assessment</h2><div id="llmAssessment" class="muted">LLM assessment disabled.</div></div></section><section class="panel"><h2>Investigation Incidents</h2><div id="incidents"></div></section><section class="split"><div class="panel"><h2>Anomalies</h2><div id="anomalies"></div></div><div class="panel"><h2>Recommendations</h2><div id="recommendations"></div></div></section></div>
|
<div data-view="overview" class="active"><section class="split"><div class="panel"><h2>Events and Anomalies</h2><canvas id="trendChart" class="chart"></canvas></div><div class="panel"><h2>Baseline and Stream Health</h2><div id="health"></div></div></section><section class="split"><div class="panel"><h2>Correlation Map</h2><canvas id="correlationGraph" class="graph"></canvas><div id="correlationGraphInfo" class="muted"></div></div><div class="panel"><h2>AI Assessment</h2><div id="llmAssessment" class="muted">LLM assessment disabled.</div></div></section><section class="panel"><h2>Investigation Incidents</h2><div id="incidents"></div></section><section class="split"><div class="panel"><h2>Anomalies</h2><div id="anomalies"></div></div><div class="panel"><h2>Recommendations</h2><div id="recommendations"></div></div></section></div>
|
||||||
<div data-view="findings"><section class="panel"><h2>Field Baseline Deviations</h2><div id="feedbackNotice" class="muted" role="status"></div><div id="fieldDeviations"></div></section><section class="panel"><h2>Related Activity Across Sources</h2><div id="relatedActivity"></div></section><section class="split"><div class="panel"><h2>Block Candidates</h2><div id="blocks"></div></div><div class="panel"><h2>Threat Intelligence</h2><div id="reputation"></div></div></section><section class="panel"><h2>Policy Findings</h2><div id="policies"></div></section></div>
|
<div data-view="findings"><section class="panel"><h2>Field Baseline Deviations</h2><div id="feedbackNotice" class="muted" role="status"></div><div id="fieldDeviations"></div></section><section class="panel"><h2>Related Activity Across Sources</h2><div id="relatedActivity"></div></section><section class="split"><div class="panel"><h2>Block Candidates</h2><div id="blocks"></div></div><div class="panel"><h2>Threat Intelligence</h2><div id="reputation"></div></div></section><section class="panel"><h2>Policy Findings</h2><div id="policies"></div></section></div>
|
||||||
<div data-view="diagnostics"><section class="panel"><h2>Diagnostics</h2><div id="diagnostics"></div></section></div>
|
<div data-view="diagnostics"><section class="panel"><h2>Diagnostics</h2><div id="diagnostics"></div></section></div>
|
||||||
<div data-view="settings"><section class="panel"><h2>Runtime Configuration</h2><form id="settingsForm"><div class="grid"><label>Log source<br><select name="log_source"><option value="local_syslog">Local syslog</option><option value="graylog_mcp">Graylog MCP</option></select></label><label>Graylog MCP URL<br><input name="graylog_mcp_url" type="url" placeholder="https://graylog.example/api/mcp"></label><label>Enabled streams<br><button type="button" id="loadStreams">Load streams</button><div id="streamPicker" class="muted">Load streams after URL and token are saved.</div></label><label>Profile editor<br><button type="button" id="loadFields">Edit first checked stream profile</button><div id="profileEditorStatus" class="muted">No profile selected for editing.</div><div id="fieldPicker" class="muted">Click Edit profile on one stream.</div></label><label>Profile name<br><input name="profile_name" placeholder="Example: Windows login behavior"></label><label>Detector thresholds (JSON)<br><textarea name="profile_detectors" placeholder='{"auth_failure":{"enabled":true,"minimum":5,"z_threshold":3}}'></textarea></label><label>Field weights (JSON)<br><textarea name="profile_field_weights" placeholder='{"url":1.5,"auth_failure_burst":2,"query_domain":{"rare_value":1.8}}'></textarea></label><label>Graylog query<br><input name="graylog_query" placeholder="*"></label><label>Graylog analysis window seconds<br><input name="graylog_range_seconds" type="number" min="60" step="60" placeholder="3600"></label><label>Graylog field mapping (JSON)<br><textarea name="graylog_field_mapping" placeholder='{"srcip":"client_ip","dstip":"server_ip","action":"event_action"}'></textarea></label><label>Graylog MCP token<br><input name="graylog_mcp_token" type="password" placeholder="Leave blank to keep current token"></label><label>Ollama model<br><input name="llm_model" placeholder="llama3.1"></label><label><input name="llm_enabled" type="checkbox"> Enable Ollama analysis</label><label><input name="threat_intel_enabled" type="checkbox"> Enable threat intelligence</label></div><p><button type="submit">Save configuration</button> <span id="settingsResult" class="muted"></span></p></form></section></div>
|
<div data-view="settings"><section class="panel"><h2>Runtime Configuration</h2><form id="settingsForm"><div class="grid"><label>Log source<br><select name="log_source"><option value="local_syslog">Local syslog</option><option value="graylog_mcp">Graylog MCP</option></select></label><label>Graylog MCP URL<br><input name="graylog_mcp_url" type="url" placeholder="https://graylog.example/api/mcp"></label><label>Enabled streams<br><button type="button" id="loadStreams">Load streams</button><div id="streamPicker" class="muted">Load streams after URL and token are saved.</div></label><label>Profile editor<br><button type="button" id="loadFields">Edit first checked stream profile</button><div id="profileEditorStatus" class="muted">No profile selected for editing.</div><div id="fieldPicker" class="muted">Click Edit profile on one stream.</div></label><label>Profile name<br><input name="profile_name" placeholder="Example: Windows login behavior"></label><label>Detector thresholds (JSON)<br><textarea name="profile_detectors" placeholder='{"auth_failure":{"enabled":true,"minimum":5,"z_threshold":3}}'></textarea></label><label>Field weights (JSON)<br><textarea name="profile_field_weights" placeholder='{"url":1.5,"auth_failure_burst":2,"query_domain":{"rare_value":1.8}}'></textarea></label><label>Graylog query<br><input name="graylog_query" placeholder="*"></label><label>Graylog analysis window seconds<br><input name="graylog_range_seconds" type="number" min="60" step="60" placeholder="3600"></label><label>Baseline bucket retention days<br><input name="baseline_retention_days" type="number" min="1" step="1" placeholder="14"></label><label>Baseline value retention days<br><input name="baseline_value_retention_days" type="number" min="1" step="1" placeholder="7"></label><label>Max values per entity field<br><input name="baseline_max_values_per_field" type="number" min="1" step="100" placeholder="2000"></label><label>Graylog field mapping (JSON)<br><textarea name="graylog_field_mapping" placeholder='{"srcip":"client_ip","dstip":"server_ip","action":"event_action"}'></textarea></label><label>Graylog MCP token<br><input name="graylog_mcp_token" type="password" placeholder="Leave blank to keep current token"></label><label>Ollama model<br><input name="llm_model" placeholder="llama3.1"></label><label><input name="llm_enabled" type="checkbox"> Enable Ollama analysis</label><label><input name="threat_intel_enabled" type="checkbox"> Enable threat intelligence</label></div><p><button type="submit">Save configuration</button> <span id="settingsResult" class="muted"></span></p></form></section></div>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
function esc(value) {
|
function esc(value) {
|
||||||
@@ -94,6 +94,7 @@ function esc(value) {
|
|||||||
function metric(label, value) {
|
function metric(label, value) {
|
||||||
return `<div class="panel"><div class="metric">${esc(value)}</div><div class="label">${esc(label)}</div></div>`;
|
return `<div class="panel"><div class="metric">${esc(value)}</div><div class="label">${esc(label)}</div></div>`;
|
||||||
}
|
}
|
||||||
|
function bytes(value) { const n=Number(value||0); if (!n) return '0 B'; const units=['B','KB','MB','GB','TB']; const i=Math.min(units.length-1, Math.floor(Math.log(n)/Math.log(1024))); return `${(n/Math.pow(1024,i)).toFixed(i?1:0)} ${units[i]}`; }
|
||||||
const tableSort = {};
|
const tableSort = {};
|
||||||
const uiCache = {correlations: [], fieldRows: []};
|
const uiCache = {correlations: [], fieldRows: []};
|
||||||
function table(rows, columns, id = '') {
|
function table(rows, columns, id = '') {
|
||||||
@@ -146,7 +147,8 @@ async function refresh() {
|
|||||||
`Policy file: <code>${esc(data.policy_path || 'none')}</code>`,
|
`Policy file: <code>${esc(data.policy_path || 'none')}</code>`,
|
||||||
`Critical anomalies: ${esc((a.critical || 0))}`,
|
`Critical anomalies: ${esc((a.critical || 0))}`,
|
||||||
`High anomalies: ${esc((a.high || 0))}`,
|
`High anomalies: ${esc((a.high || 0))}`,
|
||||||
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`
|
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`,
|
||||||
|
`Baseline DB size: ${esc(bytes((data.baseline || {}).size_bytes || 0))}`
|
||||||
].join('<br>');
|
].join('<br>');
|
||||||
document.getElementById('health').innerHTML = [
|
document.getElementById('health').innerHTML = [
|
||||||
metric('Enabled streams', enabledStreams.length), metric('Streams missing profile', streamsMissingProfile), metric('MCP events fetched', mcp.events_fetched || 0), 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)
|
||||||
|
|||||||
@@ -180,6 +180,15 @@ def build_status(
|
|||||||
anomalies = detect_source_anomalies(events, limit=anomaly_limit, baselines=profiles, field_deviations=field_deviations)
|
anomalies = detect_source_anomalies(events, limit=anomaly_limit, baselines=profiles, field_deviations=field_deviations)
|
||||||
baseline_events = baseline.ingest(events) if baseline else 0
|
baseline_events = baseline.ingest(events) if baseline else 0
|
||||||
profile_baseline_fields = baseline.ingest_profile_fields(events, stream_profiles) if baseline else 0
|
profile_baseline_fields = baseline.ingest_profile_fields(events, stream_profiles) if baseline else 0
|
||||||
|
baseline_maintenance = (
|
||||||
|
baseline.maintenance(
|
||||||
|
retention_days=int(runtime_values.get("baseline_retention_days", 14) or 14),
|
||||||
|
value_retention_days=int(runtime_values.get("baseline_value_retention_days", 7) or 7),
|
||||||
|
max_values_per_field=int(runtime_values.get("baseline_max_values_per_field", 2000) or 2000),
|
||||||
|
)
|
||||||
|
if baseline
|
||||||
|
else {}
|
||||||
|
)
|
||||||
profile_readiness = baseline.profile_readiness(stream_profiles) if baseline else []
|
profile_readiness = baseline.profile_readiness(stream_profiles) if baseline else []
|
||||||
profile_readiness = [
|
profile_readiness = [
|
||||||
{
|
{
|
||||||
@@ -225,7 +234,7 @@ def build_status(
|
|||||||
"policy_path": policy_path,
|
"policy_path": policy_path,
|
||||||
"summary": summarize_events(events),
|
"summary": summarize_events(events),
|
||||||
"anomaly_summary": anomaly_summary(anomalies),
|
"anomaly_summary": anomaly_summary(anomalies),
|
||||||
"baseline": {"enabled": bool(baseline), "sources_ready": len(profiles), "new_events_recorded": baseline_events, "profile_fields_recorded": profile_baseline_fields},
|
"baseline": {"enabled": bool(baseline), "sources_ready": len(profiles), "new_events_recorded": baseline_events, "profile_fields_recorded": profile_baseline_fields, "maintenance": baseline_maintenance, "size_bytes": baseline_maintenance.get("size_bytes", 0) if isinstance(baseline_maintenance, dict) else 0},
|
||||||
"capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status},
|
"capabilities": {"threat_intel": threat_intel_status, "graylog_mcp": mcp_status},
|
||||||
"configuration": runtime_config,
|
"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, "entity_fields": list(item.entity_fields), "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_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, "entity_fields": list(item.entity_fields), "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()],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -89,3 +90,43 @@ class BaselineTests(unittest.TestCase):
|
|||||||
auth = next(item for item in deviations if item["detector"] == "auth_failure_burst")
|
auth = next(item for item in deviations if item["detector"] == "auth_failure_burst")
|
||||||
self.assertEqual(auth["weight"], 2.0)
|
self.assertEqual(auth["weight"], 2.0)
|
||||||
self.assertGreater(auth["score"], auth["base_score"])
|
self.assertGreater(auth["score"], auth["base_score"])
|
||||||
|
|
||||||
|
def test_maintenance_prunes_old_baseline_rows(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = BaselineStore(str(Path(directory) / "baseline.sqlite3"))
|
||||||
|
profiles = parse_profiles([{"stream_id": "windows", "entity_field": "username", "categorical_fields": ["action"]}])
|
||||||
|
old = int(time.time()) - 20 * 86400
|
||||||
|
recent = int(time.time())
|
||||||
|
|
||||||
|
store.ingest([parse_log_line("srcip=10.0.0.1 dstport=443 old=1")], observed_at=old)
|
||||||
|
store.ingest_profile_fields([parse_log_line("fgai_stream_id=windows username=alice action=login old=1")], profiles, observed_at=old)
|
||||||
|
store.ingest([parse_log_line("srcip=10.0.0.2 dstport=443 recent=1")], observed_at=recent)
|
||||||
|
store.ingest_profile_fields([parse_log_line("fgai_stream_id=windows username=bob action=login recent=1")], profiles, observed_at=recent)
|
||||||
|
|
||||||
|
result = store.maintenance(retention_days=7, value_retention_days=7, max_values_per_field=2000)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(result["deleted"]["source_buckets"], 1)
|
||||||
|
self.assertGreaterEqual(result["deleted"]["profile_buckets"], 1)
|
||||||
|
self.assertGreaterEqual(result["deleted"]["seen_events"], 1)
|
||||||
|
self.assertGreaterEqual(result["deleted"]["profile_seen_events"], 1)
|
||||||
|
|
||||||
|
def test_rare_values_are_limited_per_entity(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
store = BaselineStore(str(Path(directory) / "baseline.sqlite3"))
|
||||||
|
profiles = parse_profiles([{"stream_id": "dns", "entity_field": "srcip", "categorical_fields": ["query_domain"]}])
|
||||||
|
now = int(time.time()) - 86400
|
||||||
|
baseline = [
|
||||||
|
parse_log_line(f"fgai_stream_id=dns srcip=10.0.0.5 query_domain=known-{index}.example baseline={index}")
|
||||||
|
for index in range(30)
|
||||||
|
]
|
||||||
|
store.ingest_profile_fields(baseline, profiles, observed_at=now)
|
||||||
|
|
||||||
|
burst = [
|
||||||
|
parse_log_line(f"fgai_stream_id=dns srcip=10.0.0.5 query_domain=new-{index}.example burst={index}")
|
||||||
|
for index in range(12)
|
||||||
|
]
|
||||||
|
deviations = store.profile_deviations(burst, profiles)["10.0.0.5"]
|
||||||
|
rare = [item for item in deviations if item["detector"] == "rare_value"]
|
||||||
|
|
||||||
|
self.assertLessEqual(len(rare), 5)
|
||||||
|
self.assertTrue(all(item["score"] >= 15 for item in rare))
|
||||||
|
|||||||
Reference in New Issue
Block a user