Added categorical field-value baselines.

This commit is contained in:
larssand
2026-06-22 21:56:05 +02:00
parent b0a0827d01
commit d919ef23a1

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import sqlite3 import sqlite3
import time import time
from collections import defaultdict from collections import Counter, defaultdict
from pathlib import Path from pathlib import Path
from statistics import mean, pstdev from statistics import mean, pstdev
@@ -44,6 +44,10 @@ class BaselineStore:
events integer not null, numeric_sum real not null, numeric_sum_squares real not null, events integer not null, numeric_sum real not null, numeric_sum_squares real not null,
primary key (stream_id, entity, field, bucket_start) primary key (stream_id, entity, field, bucket_start)
); );
create table if not exists profile_values (
stream_id text not null, entity text not null, field text not null, value text not null,
seen_count integer not null, primary key (stream_id, entity, field, value)
);
""" """
) )
@@ -90,6 +94,7 @@ class BaselineStore:
observed_at = observed_at or int(time.time()) observed_at = observed_at or int(time.time())
bucket = observed_at - (observed_at % self.bucket_seconds) bucket = observed_at - (observed_at % self.bucket_seconds)
pending: dict[tuple[str, str, str], list[float]] = defaultdict(lambda: [0, 0.0, 0.0]) pending: dict[tuple[str, str, str], list[float]] = defaultdict(lambda: [0, 0.0, 0.0])
pending_values: Counter[tuple[str, str, str, str]] = Counter()
for event in events: for event in events:
stream_id = event.fields.get("fgai_stream_id", "") stream_id = event.fields.get("fgai_stream_id", "")
profile = profiles.get(stream_id) profile = profiles.get(stream_id)
@@ -107,10 +112,17 @@ class BaselineStore:
pending[key][0] += 1 pending[key][0] += 1
pending[key][1] += value pending[key][1] += value
pending[key][2] += value * value pending[key][2] += value * value
if key[2] not in numeric:
raw_value = event.fields.get(key[2])
if raw_value:
pending_values[(*key, raw_value)] += 1
with self._connect() as connection: with self._connect() as connection:
for (stream_id, entity, field), values in pending.items(): for (stream_id, entity, field), values in pending.items():
connection.execute("""insert into profile_buckets values (?, ?, ?, ?, ?, ?, ?) connection.execute("""insert into profile_buckets values (?, ?, ?, ?, ?, ?, ?)
on conflict(stream_id, entity, field, 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, bucket, *values)) on conflict(stream_id, entity, field, 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, bucket, *values))
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))
return len(pending) return len(pending)
def profile_deviations(self, events: list[LogEvent], profiles: dict[str, object]) -> dict[str, list[dict[str, object]]]: def profile_deviations(self, events: list[LogEvent], profiles: dict[str, object]) -> dict[str, list[dict[str, object]]]:
@@ -145,6 +157,26 @@ class BaselineStore:
deviation = abs(current_value - mean(history)) deviation = abs(current_value - mean(history))
if deviation > (pstdev(history) or 1.0) * 3: if deviation > (pstdev(history) or 1.0) * 3:
output[entity].append({"field": field, "stream_id": stream, "score": 15, "reason": reason}) output[entity].append({"field": field, "stream_id": stream, "score": 15, "reason": reason})
# 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", ""))
if not profile:
continue
entity = event.fields.get(str(getattr(profile, "entity_field", "")).lower())
if not entity:
continue
stream = event.fields.get("fgai_stream_id", "")
for field in getattr(profile, "categorical_fields", ()):
field = str(field).lower()
value = event.fields.get(field)
if not value:
continue
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 count(*) from profile_values where stream_id=? and entity=? and field=?", (stream, entity, field)).fetchone()[0]
if known is None and known_total >= 10:
evidence = {"field": field, "stream_id": stream, "score": 12, "reason": f"new {field} value for this entity", "value": value}
if evidence not in output[entity]:
output[entity].append(evidence)
return output return output
def profiles(self, source_ips: set[str]) -> dict[str, dict[str, object]]: def profiles(self, source_ips: set[str]) -> dict[str, dict[str, object]]: