This commit is contained in:
larssand
2026-06-22 21:50:21 +02:00
parent ecb20aed4e
commit b0a0827d01
5 changed files with 49 additions and 4 deletions

View File

@@ -205,6 +205,7 @@ def run_monitor(args: argparse.Namespace) -> int:
llm_timeout=args.llm_timeout,
baseline_path=args.baseline_db,
config_path=args.config_file,
history_path=args.history_db,
)
return 0
@@ -289,6 +290,7 @@ def build_parser() -> argparse.ArgumentParser:
monitor.add_argument("--anomaly-limit", type=int, default=20, help="Maximum anomaly findings to include")
monitor.add_argument("--baseline-db", default="state/fgai-baseline.sqlite3", help="SQLite database for historical behavior baselines")
monitor.add_argument("--config-file", default="state/fgai-config.json", help="Runtime configuration written by dashboard")
monitor.add_argument("--history-db", default="state/signalscope-history.sqlite3", help="Aggregate history database for dashboard trends")
monitor.add_argument("--llm", action="store_true", help="Generate cached Ollama analyst note for dashboard")
monitor.add_argument("--llm-interval", type=int, default=300, help="Seconds between Ollama dashboard assessments")
monitor.add_argument("--model", default=None, help="Ollama model name")

View File

@@ -54,6 +54,7 @@ HTML = """<!doctype html>
.field-row code { width: fit-content; }
.field-controls { display: flex; flex-wrap: wrap; gap: 10px; }
.field-controls label { white-space: nowrap; }
.chart { width: 100%; height: 220px; background: #04182d; border: 1px solid #163b59; }
@media (max-width: 860px) { .hero, .split { grid-template-columns: 1fr; } .hero img { display: none; } }
</style>
</head>
@@ -68,7 +69,7 @@ HTML = """<!doctype html>
</div>
</section>
<nav class="tabs" aria-label="Dashboard views"><button class="tab active" data-tab="overview">Overview</button><button class="tab" data-tab="findings">Findings</button><button class="tab" data-tab="diagnostics">Diagnostics</button><button class="tab" data-tab="settings">Settings</button></nav>
<div data-view="overview" class="active"><section class="panel"><h2>AI Assessment</h2><div id="llmAssessment" class="muted">LLM assessment disabled.</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="panel"><h2>AI Assessment</h2><div id="llmAssessment" class="muted">LLM assessment disabled.</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="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="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>Graylog 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>Discover fields<br><button type="button" id="loadFields">Load selected stream fields</button><div id="fieldPicker" class="muted">Select a stream first.</div></label><label>Graylog query<br><input name="graylog_query" placeholder="*"></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>
@@ -87,6 +88,7 @@ function table(rows, columns) {
return `<div class="table-wrap"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function capability(label, state, detail) { return `<span class="capability ${state}">${esc(label)}: ${esc(detail)}</span>`; }
function drawTrend(history) { const canvas=document.getElementById('trendChart'), ctx=canvas.getContext('2d'), w=canvas.width=canvas.clientWidth*devicePixelRatio, h=canvas.height=canvas.clientHeight*devicePixelRatio; ctx.scale(devicePixelRatio,devicePixelRatio); const cw=canvas.clientWidth,ch=canvas.clientHeight; ctx.clearRect(0,0,cw,ch); const max=Math.max(1,...history.map(item=>item.events||0)); const line=(key,color)=>{ctx.strokeStyle=color;ctx.lineWidth=2;ctx.beginPath();history.forEach((item,index)=>{const x=12+index*Math.max(1,(cw-24)/Math.max(1,history.length-1));const y=ch-18-((item[key]||0)/max)*(ch-36);index?ctx.lineTo(x,y):ctx.moveTo(x,y)});ctx.stroke()}; line('events','#1ea9ff');line('anomalies','#ff5656'); }
async function refresh() {
const res = await fetch('/api/status', {cache: 'no-store'});
const data = await res.json();
@@ -96,6 +98,7 @@ async function refresh() {
const threat = (data.capabilities || {}).threat_intel || {};
const mcp = (data.capabilities || {}).graylog_mcp || {};
const configuration = data.configuration || {};
drawTrend(data.history || []);
document.getElementById('stamp').textContent = data.generated_at ? `Updated ${new Date(data.generated_at * 1000).toLocaleString()}` : 'Waiting for monitor data';
document.getElementById('metrics').innerHTML = [
metric('Total events', s.total || 0),
@@ -117,6 +120,9 @@ async function refresh() {
`High anomalies: ${esc((a.high || 0))}`,
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`
].join('<br>');
document.getElementById('health').innerHTML = [
metric('Baseline sources ready', baseline.sources_ready || 0), metric('MCP events fetched', mcp.events_fetched || 0), metric('Profiles active', (data.stream_profiles || []).length), metric('Correlated entities', (data.cross_source_correlations || []).length)
].join('');
const llmText = llm.text ? esc(llm.text).replace(/\\n/g, '<br>') : esc(llm.error || 'LLM assessment disabled or waiting for first run.');
document.getElementById('llmAssessment').innerHTML = `<div>Status: <code>${esc(llm.status || 'unknown')}</code></div><p>${llmText}</p>`;
document.getElementById('anomalies').innerHTML = table(data.anomalies || [], [

28
src/fgai/history.py Normal file
View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import sqlite3
import time
from pathlib import Path
class HistoryStore:
def __init__(self, path: str) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(self.path) as connection:
connection.execute("create table if not exists snapshots (at integer primary key, events integer, anomalies integer, critical integer, high integer, baseline_ready integer, mcp_events integer)")
def record(self, status: dict[str, object]) -> None:
summary = status.get("summary", {}) if isinstance(status.get("summary"), dict) else {}
anomalies = status.get("anomaly_summary", {}) if isinstance(status.get("anomaly_summary"), dict) else {}
baseline = status.get("baseline", {}) if isinstance(status.get("baseline"), dict) else {}
capabilities = status.get("capabilities", {}) if isinstance(status.get("capabilities"), dict) else {}
mcp = capabilities.get("graylog_mcp", {}) if isinstance(capabilities.get("graylog_mcp"), dict) else {}
with sqlite3.connect(self.path) as connection:
connection.execute("insert or replace into snapshots values (?, ?, ?, ?, ?, ?, ?)", (int(time.time()), int(summary.get("total", 0) or 0), int(anomalies.get("total", 0) or 0), int(anomalies.get("critical", 0) or 0), int(anomalies.get("high", 0) or 0), int(baseline.get("sources_ready", 0) or 0), int(mcp.get("events_fetched", 0) or 0)))
connection.execute("delete from snapshots where at < ?", (int(time.time()) - 7 * 86400,))
def recent(self, limit: int = 288) -> list[dict[str, int]]:
with sqlite3.connect(self.path) as connection:
rows = connection.execute("select at, events, anomalies, critical, high, baseline_ready, mcp_events from snapshots order by at desc limit ?", (limit,)).fetchall()
return [dict(zip(("at", "events", "anomalies", "critical", "high", "baseline_ready", "mcp_events"), row)) for row in reversed(rows)]

View File

@@ -11,6 +11,7 @@ from .correlation import correlate_source_ips
from .event_context import build_event_context
from .graylog_mcp import GraylogMcpClient
from .graylog_source import GraylogStreamSource
from .history import HistoryStore
from .llm import ollama_dashboard_assessment
from .logs import local_in_failures, read_events, summarize_events, top_field_values
from .mitigation import parse_allowlist, suggest_block_candidates
@@ -29,6 +30,7 @@ def build_status(
anomaly_limit: int = 20,
baseline_path: str | None = None,
config_path: str | None = None,
history_path: str | None = None,
) -> dict[str, object]:
config_store = ConfigStore(config_path) if config_path else None
config_exists = bool(config_store and config_store.path.exists())
@@ -93,7 +95,7 @@ def build_status(
except Exception as exc:
policy_error = str(exc)
return {
status = {
"generated_at": int(time.time()),
"log_path": log_path,
"policy_path": policy_path,
@@ -149,6 +151,11 @@ def build_status(
"policy_findings": policy_findings,
"policy_error": policy_error,
}
if history_path:
history = HistoryStore(history_path)
history.record(status)
status["history"] = history.recent()
return status
def add_llm_assessment(status: dict[str, object], *, previous: str | None = None, model: str | None = None, timeout: int | None = None) -> None:
@@ -190,6 +197,7 @@ def monitor_loop(
llm_timeout: int | None = None,
baseline_path: str | None = None,
config_path: str | None = None,
history_path: str | None = None,
) -> None:
print(f"Monitoring {log_path}")
print(f"Writing status to {output}")
@@ -201,7 +209,7 @@ def monitor_loop(
effective_model = str(runtime.get("llm_model") or llm_model or "")
status = build_status(
log_path, policy_path=policy_path, anomaly_limit=anomaly_limit,
baseline_path=baseline_path, config_path=config_path,
baseline_path=baseline_path, config_path=config_path, history_path=history_path,
)
if effective_llm:
now = int(time.time())

View File

@@ -13,6 +13,7 @@ POLICY_FILE="${FGAI_POLICY_FILE:-$ROOT_DIR/exports/policies.json}"
STATE_FILE="${FGAI_STATE_FILE:-$ROOT_DIR/state/fgai-status.json}"
CONFIG_FILE="${FGAI_CONFIG_FILE:-$ROOT_DIR/state/fgai-config.json}"
BASELINE_DB="${FGAI_BASELINE_DB:-$ROOT_DIR/state/fgai-baseline.sqlite3}"
HISTORY_DB="${FGAI_HISTORY_DB:-$ROOT_DIR/state/signalscope-history.sqlite3}"
MONITOR_INTERVAL="${FGAI_MONITOR_INTERVAL:-10}"
LLM_ENABLED="${FGAI_LLM:-0}"
LLM_INTERVAL="${FGAI_LLM_INTERVAL:-300}"
@@ -120,7 +121,7 @@ start_monitor() {
return 0
fi
monitor_args=(monitor --logs "$LOG_FILE" --output "$STATE_FILE" --interval "$MONITOR_INTERVAL" --baseline-db "$BASELINE_DB" --config-file "$CONFIG_FILE")
monitor_args=(monitor --logs "$LOG_FILE" --output "$STATE_FILE" --interval "$MONITOR_INTERVAL" --baseline-db "$BASELINE_DB" --history-db "$HISTORY_DB" --config-file "$CONFIG_FILE")
if [ -f "$POLICY_FILE" ]; then
monitor_args+=(--policies "$POLICY_FILE")
fi