diff --git a/README.md b/README.md index 712b22e..3895955 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ Or use the helper script, which creates/uses `.venv` automatically and runs `pip - Continuous monitor writing `state/fgai-status.json` - Local dashboard at `http://127.0.0.1:8088` +Enable cached Ollama analyst notes in the dashboard: + +```bash +FGAI_LLM=1 OLLAMA_MODEL=llama3.1 ./start.sh restart +``` + +The monitor refreshes deterministic detections every `FGAI_MONITOR_INTERVAL` seconds and refreshes the LLM note every `FGAI_LLM_INTERVAL` seconds, default `300`. + The script activates `.venv` inside the script process. If you also want your current shell prompt to show the venv, run: ```bash @@ -182,6 +190,8 @@ end - `OLLAMA_HOST`: defaults to `http://127.0.0.1:11434`. - `OLLAMA_MODEL`: defaults to `llama3.3`. - `OLLAMA_TIMEOUT`: Ollama request timeout in seconds, defaults to `180`. +- `FGAI_LLM`: set to `1` to enable dashboard Ollama analyst notes. +- `FGAI_LLM_INTERVAL`: seconds between dashboard LLM notes, defaults to `300`. - `FGAI_THREAT_INTEL`: set to `1` to enable external threat intelligence lookups. - `ABUSEIPDB_API_KEY`: AbuseIPDB API key for public IP reputation enrichment. - `ABUSEIPDB_MAX_AGE_DAYS`: report age window for AbuseIPDB, defaults to `90`. diff --git a/src/fgai/cli.py b/src/fgai/cli.py index cace29d..f00f977 100644 --- a/src/fgai/cli.py +++ b/src/fgai/cli.py @@ -192,6 +192,10 @@ def run_monitor(args: argparse.Namespace) -> int: policy_path=args.policies, interval=args.interval, anomaly_limit=args.anomaly_limit, + llm=args.llm, + llm_interval=args.llm_interval, + llm_model=args.model, + llm_timeout=args.llm_timeout, ) return 0 @@ -272,6 +276,10 @@ def build_parser() -> argparse.ArgumentParser: monitor.add_argument("--policies", default=None, help="Optional FortiGate policy JSON/config file to audit continuously") monitor.add_argument("--interval", type=int, default=10, help="Seconds between analysis runs") monitor.add_argument("--anomaly-limit", type=int, default=20, help="Maximum anomaly findings to include") + 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") + monitor.add_argument("--llm-timeout", type=int, default=None, help="Ollama request timeout in seconds") monitor.set_defaults(func=run_monitor) dashboard = subparsers.add_parser("dashboard", help="Serve local fgAI dashboard") diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index d1cb241..2f60bda 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -46,6 +46,7 @@ HTML = """

Live Status

Waiting for monitor data.
+

AI Assessment

LLM assessment disabled.

Anomalies

Recommendations

Block Candidates

@@ -84,6 +85,9 @@ async function refresh() { `Critical anomalies: ${esc((a.critical || 0))}`, `High anomalies: ${esc((a.high || 0))}` ].join('
'); + const llm = data.llm_assessment || {}; + const llmText = llm.text ? esc(llm.text).replace(/\\n/g, '
') : esc(llm.error || 'LLM assessment disabled or waiting for first run.'); + document.getElementById('llmAssessment').innerHTML = `
Status: ${esc(llm.status || 'unknown')}

${llmText}

`; document.getElementById('anomalies').innerHTML = table(data.anomalies || [], [ {label:'Source', key:'subject'}, {label:'Score', key:'score'}, diff --git a/src/fgai/llm.py b/src/fgai/llm.py index 7899db5..045697a 100644 --- a/src/fgai/llm.py +++ b/src/fgai/llm.py @@ -46,3 +46,28 @@ def ollama_summary( with request.urlopen(req, timeout=selected_timeout) as response: data = json.loads(response.read().decode("utf-8")) return str(data.get("response", "")).strip() + + +def ollama_dashboard_assessment(analysis: dict[str, object], model: str | None = None, timeout: int | None = None) -> str: + compact = { + "summary": analysis.get("summary", {}), + "anomaly_summary": analysis.get("anomaly_summary", {}), + "top_anomalies": analysis.get("anomalies", [])[:5], + "top_recommendations": analysis.get("recommendations", [])[:5], + "block_candidates": analysis.get("block_candidates", [])[:5], + "policy_findings": analysis.get("policy_findings", [])[:5], + } + return ollama_summary( + [], + [], + model, + analysis={ + "task": ( + "Write a concise dashboard analyst note for a FortiGate admin. " + "Explain likely cause, whether this looks malicious or noisy, and the next action. " + "Mention policyid=0 as implicit deny/drop, not an editable policy." + ), + "data": compact, + }, + timeout=timeout, + ) diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py index 471129d..5961882 100644 --- a/src/fgai/monitor.py +++ b/src/fgai/monitor.py @@ -5,6 +5,7 @@ import time from pathlib import Path from .anomaly import anomaly_summary, detect_source_anomalies +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 from .policies import audit_policies, read_policies @@ -94,6 +95,24 @@ def build_status( } +def add_llm_assessment(status: dict[str, object], *, previous: str | None = None, model: str | None = None, timeout: int | None = None) -> None: + try: + status["llm_assessment"] = { + "enabled": True, + "status": "ok", + "generated_at": int(time.time()), + "text": ollama_dashboard_assessment(status, model=model, timeout=timeout), + } + except Exception as exc: + status["llm_assessment"] = { + "enabled": True, + "status": "error", + "generated_at": int(time.time()), + "error": str(exc), + "text": previous or "", + } + + def write_status(status: dict[str, object], output: str) -> None: output_path = Path(output) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -109,10 +128,33 @@ def monitor_loop( policy_path: str | None = None, interval: int = 10, anomaly_limit: int = 20, + llm: bool = False, + llm_interval: int = 300, + llm_model: str | None = None, + llm_timeout: int | None = None, ) -> None: print(f"Monitoring {log_path}") print(f"Writing status to {output}") + last_llm_at = 0 + last_llm_text: str | None = None while True: status = build_status(log_path, policy_path=policy_path, anomaly_limit=anomaly_limit) + if llm: + now = int(time.time()) + if now - last_llm_at >= llm_interval: + add_llm_assessment(status, previous=last_llm_text, model=llm_model, timeout=llm_timeout) + assessment = status.get("llm_assessment", {}) + if isinstance(assessment, dict): + last_llm_text = str(assessment.get("text", "") or last_llm_text or "") + last_llm_at = now + else: + status["llm_assessment"] = { + "enabled": True, + "status": "cached", + "generated_at": last_llm_at, + "text": last_llm_text or "", + } + else: + status["llm_assessment"] = {"enabled": False, "status": "disabled", "text": ""} write_status(status, output) time.sleep(interval) diff --git a/start.sh b/start.sh index 7873e60..63ef92f 100755 --- a/start.sh +++ b/start.sh @@ -10,6 +10,10 @@ LISTENER_LOG="${FGAI_LISTENER_LOG:-$ROOT_DIR/logs/fgai-listener.log}" POLICY_FILE="${FGAI_POLICY_FILE:-$ROOT_DIR/exports/policies.json}" STATE_FILE="${FGAI_STATE_FILE:-$ROOT_DIR/state/fgai-status.json}" MONITOR_INTERVAL="${FGAI_MONITOR_INTERVAL:-10}" +LLM_ENABLED="${FGAI_LLM:-0}" +LLM_INTERVAL="${FGAI_LLM_INTERVAL:-300}" +LLM_TIMEOUT="${OLLAMA_TIMEOUT:-180}" +LLM_MODEL="${OLLAMA_MODEL:-}" DASHBOARD_HOST="${FGAI_DASHBOARD_HOST:-127.0.0.1}" DASHBOARD_PORT="${FGAI_DASHBOARD_PORT:-8088}" LISTENER_PID_FILE="${FGAI_LISTENER_PID_FILE:-$ROOT_DIR/run/fgai-listener.pid}" @@ -27,6 +31,7 @@ usage() { printf ' FGAI_SYSLOG_FILE=%s\n' "$LOG_FILE" printf ' FGAI_LISTENER_LOG=%s\n' "$LISTENER_LOG" printf ' FGAI_DASHBOARD_PORT=%s\n' "$DASHBOARD_PORT" + printf ' FGAI_LLM=%s\n' "$LLM_ENABLED" } activate_venv_for_script() { @@ -109,6 +114,12 @@ start_monitor() { if [ -f "$POLICY_FILE" ]; then monitor_args+=(--policies "$POLICY_FILE") fi + if [ "$LLM_ENABLED" = "1" ] || [ "$LLM_ENABLED" = "true" ]; then + monitor_args+=(--llm --llm-interval "$LLM_INTERVAL" --llm-timeout "$LLM_TIMEOUT") + if [ -n "$LLM_MODEL" ]; then + monitor_args+=(--model "$LLM_MODEL") + fi + fi nohup "$VENV_DIR/bin/fgai" "${monitor_args[@]}" > "$MONITOR_LOG" 2>&1 & printf '%s\n' "$!" > "$MONITOR_PID_FILE" diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 8d3f8e7..f40421f 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -1,8 +1,9 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch -from fgai.monitor import build_status, write_status +from fgai.monitor import add_llm_assessment, build_status, write_status class MonitorTests(unittest.TestCase): @@ -33,6 +34,24 @@ class MonitorTests(unittest.TestCase): self.assertTrue(output.exists()) + def test_add_llm_assessment_records_error_without_ollama(self): + status = {"summary": {}, "anomalies": []} + + with patch("fgai.monitor.ollama_dashboard_assessment", side_effect=TimeoutError("timeout")): + add_llm_assessment(status, previous="old text") + + self.assertEqual(status["llm_assessment"]["status"], "error") + self.assertEqual(status["llm_assessment"]["text"], "old text") + + def test_add_llm_assessment_records_text(self): + status = {"summary": {}, "anomalies": []} + + with patch("fgai.monitor.ollama_dashboard_assessment", return_value="looks noisy"): + add_llm_assessment(status) + + self.assertEqual(status["llm_assessment"]["status"], "ok") + self.assertEqual(status["llm_assessment"]["text"], "looks noisy") + if __name__ == "__main__": unittest.main()