From f7611ef2b39ae5aa45a7a89300cccb36789571397c5fb12e6725d19a9cba40be Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 18 Jun 2026 22:51:54 +0200 Subject: [PATCH] ad VS --- README.md | 16 +++++ src/fgai/cli.py | 50 +++++++++++++++ src/fgai/dashboard.py | 20 ++++++ src/fgai/models.py | 16 +++++ src/fgai/monitor.py | 26 ++++++++ src/fgai/recommendations.py | 114 ++++++++++++++++++++++++++++++++++ src/fgai/threat_intel.py | 94 ++++++++++++++++++++++++++++ tests/test_recommendations.py | 38 ++++++++++++ 8 files changed, 374 insertions(+) create mode 100644 src/fgai/recommendations.py create mode 100644 src/fgai/threat_intel.py create mode 100644 tests/test_recommendations.py diff --git a/README.md b/README.md index 1af3d05..bb91746 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,20 @@ fgai detect-anomalies --logs logs/fg_syslog.jsonl --min-score 35 fgai detect-anomalies --logs logs/fg_syslog.jsonl --min-score 35 --llm --llm-timeout 300 ``` +Generate response and policy recommendations: + +```bash +fgai recommend --logs logs/fg_syslog.jsonl --min-score 35 +``` + +Optional external reputation enrichment is disabled by default. To use VirusTotal for public source/destination IP reputation: + +```bash +export FGAI_THREAT_INTEL=1 +export VIRUSTOTAL_API_KEY='...' +fgai recommend --logs logs/fg_syslog.jsonl --min-score 35 --threat-intel +``` + Listen for FortiGate syslog locally: ```bash @@ -159,6 +173,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_THREAT_INTEL`: set to `1` to enable external threat intelligence lookups. +- `VIRUSTOTAL_API_KEY`: VirusTotal API key for public IP reputation enrichment. ## Safety Model diff --git a/src/fgai/cli.py b/src/fgai/cli.py index 957cba3..cace29d 100644 --- a/src/fgai/cli.py +++ b/src/fgai/cli.py @@ -11,8 +11,10 @@ from .llm import ollama_summary from .logs import local_in_failures, read_events, summarize_events, top_field_values from .mitigation import FortiGateClient, parse_allowlist, suggest_block_candidates from .policies import audit_policies, read_policies +from .recommendations import build_recommendations from .syslog_server import listen_udp_syslog from .monitor import monitor_loop +from .threat_intel import enrich_ips, is_public_ip def _print_json(data: object) -> None: @@ -119,6 +121,43 @@ def detect_anomalies(args: argparse.Namespace) -> int: return 0 +def recommend(args: argparse.Namespace) -> int: + events = read_events(args.logs) + anomalies = detect_source_anomalies(events, limit=args.limit) + intel_ips = sorted( + { + ip + for event in events + for ip in (event.src_ip, event.dst_ip) + if is_public_ip(ip) + } + ) + reputation = enrich_ips(intel_ips, limit=args.intel_limit) if args.threat_intel else {} + recommendations = build_recommendations(events, anomalies, reputation) + output = { + "recommendations": [ + { + "subject": item.subject, + "score": item.score, + "severity": item.severity, + "title": item.title, + "recommendation": item.recommendation, + "reasons": item.reasons, + "related_policy_ids": item.related_policy_ids, + "related_services": item.related_services, + } + for item in recommendations + if item.score >= args.min_score + ], + "reputation": reputation, + } + _print_json(output) + if args.llm: + print("\nLLM summary:") + print(ollama_summary([], [], args.model, analysis=output, timeout=args.llm_timeout)) + return 0 + + def test_connection(args: argparse.Namespace) -> int: client = FortiGateClient.from_env() status = client.system_status() @@ -186,6 +225,17 @@ def build_parser() -> argparse.ArgumentParser: anomalies.add_argument("--llm-timeout", type=int, default=None, help="Ollama request timeout in seconds") anomalies.set_defaults(func=detect_anomalies) + recommendations = subparsers.add_parser("recommend", help="Generate policy and response recommendations from anomalies") + recommendations.add_argument("--logs", required=True, help="Path to syslog JSONL or key/value log file") + recommendations.add_argument("--limit", type=int, default=20, help="Maximum anomaly findings to evaluate") + recommendations.add_argument("--min-score", type=int, default=35, help="Minimum recommendation score to output") + recommendations.add_argument("--threat-intel", action="store_true", help="Use enabled external threat intelligence lookups") + recommendations.add_argument("--intel-limit", type=int, default=25, help="Maximum public IPs to enrich") + recommendations.add_argument("--llm", action="store_true", help="Ask local Ollama to summarize results") + recommendations.add_argument("--model", default=None, help="Ollama model name") + recommendations.add_argument("--llm-timeout", type=int, default=None, help="Ollama request timeout in seconds") + recommendations.set_defaults(func=recommend) + policies = subparsers.add_parser("audit-policies", help="Audit FortiOS firewall policy config") policies.add_argument("--config", required=True, help="Path to FortiOS config backup") policies.add_argument("--llm", action="store_true", help="Ask local Ollama to summarize results") diff --git a/src/fgai/dashboard.py b/src/fgai/dashboard.py index ae5d7bd..d1cb241 100644 --- a/src/fgai/dashboard.py +++ b/src/fgai/dashboard.py @@ -47,7 +47,9 @@ HTML = """

Anomalies

+

Recommendations

Block Candidates

+

Threat Intelligence

Policy Findings

Diagnostics

@@ -89,11 +91,29 @@ async function refresh() { {label:'Confidence', key:'confidence'}, {label:'Reasons', render:r => esc((r.reasons || []).join('; '))} ]); + document.getElementById('recommendations').innerHTML = table(data.recommendations || [], [ + {label:'Subject', key:'subject'}, + {label:'Score', key:'score'}, + {label:'Severity', render:r => `${esc(r.severity)}`}, + {label:'Title', key:'title'}, + {label:'Recommendation', key:'recommendation'}, + {label:'Policies', render:r => esc((r.related_policy_ids || []).join(', '))}, + {label:'Services', render:r => esc((r.related_services || []).join(', '))} + ]); document.getElementById('blocks').innerHTML = table(data.block_candidates || [], [ {label:'Source', key:'src_ip'}, {label:'Score', key:'score'}, {label:'Reasons', render:r => esc((r.reasons || []).join('; '))} ]); + const reputationRows = Object.entries(data.reputation || {}).map(([ip, intel]) => ({ip, ...intel})); + document.getElementById('reputation').innerHTML = table(reputationRows, [ + {label:'IP', key:'ip'}, + {label:'Provider', key:'provider'}, + {label:'Status', key:'status'}, + {label:'Score', key:'score'}, + {label:'Malicious', key:'malicious'}, + {label:'Suspicious', key:'suspicious'} + ]); document.getElementById('policies').innerHTML = table(data.policy_findings || [], [ {label:'Severity', render:r => `${esc(r.severity)}`}, {label:'Reference', key:'reference'}, diff --git a/src/fgai/models.py b/src/fgai/models.py index 994c450..72161c9 100644 --- a/src/fgai/models.py +++ b/src/fgai/models.py @@ -12,6 +12,10 @@ class LogEvent: def src_ip(self) -> str | None: return self.fields.get("srcip") or self.fields.get("src_ip") or self.fields.get("source_ip") + @property + def dst_ip(self) -> str | None: + return self.fields.get("dstip") or self.fields.get("dst_ip") or self.fields.get("destination_ip") + @property def subtype(self) -> str: return self.fields.get("subtype", "").lower() @@ -62,3 +66,15 @@ class AnomalyFinding: confidence: str reasons: list[str] evidence: dict[str, int | str | float] + + +@dataclass(frozen=True) +class Recommendation: + subject: str + score: int + severity: str + title: str + recommendation: str + reasons: list[str] + related_policy_ids: list[str] = field(default_factory=list) + related_services: list[str] = field(default_factory=list) diff --git a/src/fgai/monitor.py b/src/fgai/monitor.py index dd9bd97..471129d 100644 --- a/src/fgai/monitor.py +++ b/src/fgai/monitor.py @@ -8,6 +8,8 @@ from .anomaly import anomaly_summary, detect_source_anomalies 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 +from .recommendations import build_recommendations +from .threat_intel import enrich_ips, is_public_ip def build_status( @@ -20,6 +22,16 @@ def build_status( ) -> dict[str, object]: events = read_events(log_path) if Path(log_path).exists() else [] anomalies = detect_source_anomalies(events, limit=anomaly_limit) + intel_ips = sorted( + { + ip + for event in events + for ip in (event.src_ip, event.dst_ip) + if is_public_ip(ip) + } + ) + reputation = enrich_ips(intel_ips, limit=25) + recommendations = build_recommendations(events, anomalies, reputation) block_candidates = suggest_block_candidates( events, min_events=min_block_events, @@ -59,6 +71,20 @@ def build_status( } for finding in anomalies ], + "recommendations": [ + { + "subject": item.subject, + "score": item.score, + "severity": item.severity, + "title": item.title, + "recommendation": item.recommendation, + "reasons": item.reasons, + "related_policy_ids": item.related_policy_ids, + "related_services": item.related_services, + } + for item in recommendations + ], + "reputation": reputation, "block_candidates": [ {"src_ip": candidate.src_ip, "score": candidate.score, "reasons": candidate.reasons} for candidate in block_candidates diff --git a/src/fgai/recommendations.py b/src/fgai/recommendations.py new file mode 100644 index 0000000..5eed51b --- /dev/null +++ b/src/fgai/recommendations.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from collections import Counter, defaultdict + +from .models import AnomalyFinding, LogEvent, Recommendation +from .threat_intel import is_public_ip + + +def _severity(score: int) -> str: + if score >= 80: + return "critical" + if score >= 60: + return "high" + if score >= 35: + return "medium" + return "low" + + +def _top_values(events: list[LogEvent], field: str, limit: int = 5) -> list[str]: + counter: Counter[str] = Counter() + for event in events: + value = event.fields.get(field) + if value: + counter[value] += 1 + return [value for value, _ in counter.most_common(limit)] + + +def _events_by_src(events: list[LogEvent]) -> dict[str, list[LogEvent]]: + grouped: dict[str, list[LogEvent]] = defaultdict(list) + for event in events: + if event.src_ip: + grouped[event.src_ip].append(event) + return grouped + + +def build_recommendations( + events: list[LogEvent], + anomalies: list[AnomalyFinding], + reputation: dict[str, dict[str, object]] | None = None, +) -> list[Recommendation]: + reputation = reputation or {} + grouped = _events_by_src(events) + recommendations: list[Recommendation] = [] + + for anomaly in anomalies: + src_events = grouped.get(anomaly.subject, []) + if not src_events: + continue + + policy_ids = _top_values(src_events, "policyid") + services = _top_values(src_events, "service") + dst_ips = [event.dst_ip for event in src_events if event.dst_ip] + public_dst = [ip for ip in _top_values(src_events, "dstip", limit=10) if is_public_ip(ip)] + bad_reputation = [ + f"{ip}:score={intel.get('score')} status={intel.get('status')}" + for ip, intel in reputation.items() + if int(intel.get("score", 0) or 0) >= 50 and (ip == anomaly.subject or ip in dst_ips) + ] + + score = anomaly.score + reasons = list(anomaly.reasons) + if bad_reputation: + score = min(100, score + 20) + reasons.append(f"threat intelligence hit ({'; '.join(bad_reputation[:3])})") + + if is_public_ip(anomaly.subject) and anomaly.score >= 60: + title = "Quarantine or block suspicious public source" + action = ( + "Inspect the matching FortiGate logs and policy IDs, then quarantine the source IP " + "temporarily if the traffic is unsolicited or UTM-confirmed. Convert to a permanent " + "address object/block only after reputation and business impact are verified." + ) + elif public_dst and bad_reputation: + title = "Investigate risky destination reputation" + action = ( + "Inspect the affected internal client, DNS history, and policy path. Consider blocking " + "the destination with an address object or ISDB/category control if reputation remains malicious." + ) + elif anomaly.evidence.get("distinct_destinations", 0) >= 10: + title = "Investigate scan-like or fan-out traffic" + action = ( + "Inspect the source host and the matching policies. If this is not expected discovery or monitoring, " + "limit allowed destinations/services and add IPS/application control on the policy." + ) + elif anomaly.evidence.get("distinct_services", 0) >= 8: + title = "Restrict broad service usage" + action = ( + "Review the matching policy services. Replace broad service objects such as ALL with the observed " + "business-required services only." + ) + elif anomaly.evidence.get("utm_events", 0): + title = "Review UTM-triggering traffic" + action = ( + "Inspect the IPS/AV/WebFilter event details and policy. Keep or enable UTM profiles on this policy, " + "and tighten source/destination scope if the traffic is not expected." + ) + else: + title = "Review traffic anomaly" + action = "Inspect the related policy and host behavior before changing enforcement." + + recommendations.append( + Recommendation( + subject=anomaly.subject, + score=min(100, score), + severity=_severity(min(100, score)), + title=title, + recommendation=action, + reasons=reasons, + related_policy_ids=policy_ids, + related_services=services, + ) + ) + + return sorted(recommendations, key=lambda item: item.score, reverse=True) diff --git a/src/fgai/threat_intel.py b/src/fgai/threat_intel.py new file mode 100644 index 0000000..a744d97 --- /dev/null +++ b/src/fgai/threat_intel.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import ipaddress +import json +import os +import time +from pathlib import Path +from urllib import error, request + + +def is_public_ip(value: str | None) -> bool: + if not value: + return False + try: + return ipaddress.ip_address(value).is_global + except ValueError: + return False + + +class ThreatIntelClient: + def __init__(self, *, cache_file: str = "state/threat-intel-cache.json", ttl_seconds: int = 86400) -> None: + self.enabled = os.getenv("FGAI_THREAT_INTEL", "").lower() in {"1", "true", "yes", "on"} + self.virustotal_key = os.getenv("VIRUSTOTAL_API_KEY") + self.cache_path = Path(cache_file) + self.ttl_seconds = ttl_seconds + self.cache = self._read_cache() + + def _read_cache(self) -> dict[str, dict[str, object]]: + if not self.cache_path.exists(): + return {} + try: + return json.loads(self.cache_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + def _write_cache(self) -> None: + self.cache_path.parent.mkdir(parents=True, exist_ok=True) + self.cache_path.write_text(json.dumps(self.cache, indent=2, sort_keys=True), encoding="utf-8") + + def lookup_ip(self, ip: str) -> dict[str, object]: + if not is_public_ip(ip): + return {"ip": ip, "provider": "local", "status": "skipped", "reason": "not_public_ip", "score": 0} + cached = self.cache.get(ip) + now = int(time.time()) + if cached and now - int(cached.get("cached_at", 0)) < self.ttl_seconds: + return cached + if not self.enabled: + return {"ip": ip, "provider": "none", "status": "disabled", "score": 0} + if not self.virustotal_key: + return {"ip": ip, "provider": "virustotal", "status": "missing_api_key", "score": 0} + + result = self._lookup_virustotal_ip(ip) + result["cached_at"] = now + self.cache[ip] = result + self._write_cache() + return result + + def _lookup_virustotal_ip(self, ip: str) -> dict[str, object]: + req = request.Request( + f"https://www.virustotal.com/api/v3/ip_addresses/{ip}", + headers={"x-apikey": self.virustotal_key or "", "accept": "application/json"}, + ) + try: + with request.urlopen(req, timeout=20) as response: + data = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + return {"ip": ip, "provider": "virustotal", "status": f"http_{exc.code}", "score": 0} + except Exception as exc: + return {"ip": ip, "provider": "virustotal", "status": "error", "error": str(exc), "score": 0} + + stats = data.get("data", {}).get("attributes", {}).get("last_analysis_stats", {}) + malicious = int(stats.get("malicious", 0) or 0) + suspicious = int(stats.get("suspicious", 0) or 0) + harmless = int(stats.get("harmless", 0) or 0) + undetected = int(stats.get("undetected", 0) or 0) + score = min(100, malicious * 20 + suspicious * 10) + return { + "ip": ip, + "provider": "virustotal", + "status": "ok", + "score": score, + "malicious": malicious, + "suspicious": suspicious, + "harmless": harmless, + "undetected": undetected, + } + + +def enrich_ips(ips: list[str], *, cache_file: str = "state/threat-intel-cache.json", limit: int = 25) -> dict[str, dict[str, object]]: + client = ThreatIntelClient(cache_file=cache_file) + enriched: dict[str, dict[str, object]] = {} + for ip in ips[:limit]: + enriched[ip] = client.lookup_ip(ip) + return enriched diff --git a/tests/test_recommendations.py b/tests/test_recommendations.py new file mode 100644 index 0000000..acc8410 --- /dev/null +++ b/tests/test_recommendations.py @@ -0,0 +1,38 @@ +import os +import unittest +from unittest.mock import patch + +from fgai.anomaly import detect_source_anomalies +from fgai.logs import parse_log_line +from fgai.recommendations import build_recommendations +from fgai.threat_intel import ThreatIntelClient + + +class RecommendationTests(unittest.TestCase): + def test_recommends_review_for_utm_anomaly(self): + events = [ + parse_log_line( + 'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 policyid=4 ' + 'service=https action=blocked severity=critical' + ) + for _ in range(5) + ] + anomalies = detect_source_anomalies(events) + + recommendations = build_recommendations(events, anomalies) + + self.assertGreaterEqual(recommendations[0].score, 60) + self.assertIn("4", recommendations[0].related_policy_ids) + self.assertIn("https", recommendations[0].related_services) + + def test_threat_intel_disabled_by_default(self): + with patch.dict(os.environ, {}, clear=True): + client = ThreatIntelClient(cache_file="/tmp/fgai-test-threat-cache.json") + + result = client.lookup_ip("8.8.8.8") + + self.assertEqual(result["status"], "disabled") + + +if __name__ == "__main__": + unittest.main()