first add

This commit is contained in:
larssand
2026-06-18 21:03:07 +02:00
parent 191ac4cf55
commit 05038c6ad7
22 changed files with 701 additions and 0 deletions

4
src/fgai/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""Local FortiGate AI/ML inspection helpers."""
__all__ = ["__version__"]
__version__ = "0.1.0"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

115
src/fgai/cli.py Normal file
View File

@@ -0,0 +1,115 @@
from __future__ import annotations
import argparse
import json
import sys
from .llm import ollama_summary
from .logs import read_events, summarize_events
from .mitigation import FortiGateClient, parse_allowlist, suggest_block_candidates
from .policies import audit_policies, read_policies
def _print_json(data: object) -> None:
print(json.dumps(data, indent=2, sort_keys=True))
def analyze_logs(args: argparse.Namespace) -> int:
events = read_events(args.logs)
candidates = suggest_block_candidates(
events,
min_events=args.min_events,
min_score=args.min_score,
allowlist=parse_allowlist(args.allowlist),
)
_print_json(
{
"summary": summarize_events(events),
"block_candidates": [
{"src_ip": candidate.src_ip, "score": candidate.score, "reasons": candidate.reasons}
for candidate in candidates
],
}
)
if args.llm:
print("\nLLM summary:")
print(ollama_summary([], candidates, args.model))
return 0
def audit_policy_file(args: argparse.Namespace) -> int:
findings = audit_policies(read_policies(args.config))
_print_json([finding.__dict__ for finding in findings])
if args.llm:
print("\nLLM summary:")
print(ollama_summary(findings, [], args.model))
return 0
def suggest_blocks(args: argparse.Namespace) -> int:
events = read_events(args.logs)
candidates = suggest_block_candidates(
events,
min_events=args.min_events,
min_score=args.min_score,
allowlist=parse_allowlist(args.allowlist),
)
if not candidates:
print("No block candidates met the current thresholds.")
return 0
for candidate in candidates:
print(f"{candidate.src_ip} score={candidate.score} reasons={', '.join(candidate.reasons)}")
if not args.execute:
print(" dry-run: not blocked")
continue
client = FortiGateClient.from_env()
reason = f"fgAI UTM mitigation score={candidate.score}"
response = client.quarantine_ip(candidate.src_ip, args.expiry_minutes, reason)
print(f" blocked: {response}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Local FortiGate AI/ML inspection tool")
subparsers = parser.add_subparsers(required=True)
logs = subparsers.add_parser("analyze-logs", help="Analyze local FortiGate logs")
logs.add_argument("--logs", required=True, help="Path to syslog JSONL or key/value log file")
logs.add_argument("--min-events", type=int, default=3)
logs.add_argument("--min-score", type=int, default=7)
logs.add_argument("--allowlist", default=None, help="Comma-separated IPs/CIDRs never to block")
logs.add_argument("--llm", action="store_true", help="Ask local Ollama to summarize results")
logs.add_argument("--model", default=None, help="Ollama model name")
logs.set_defaults(func=analyze_logs)
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")
policies.add_argument("--model", default=None, help="Ollama model name")
policies.set_defaults(func=audit_policy_file)
blocks = subparsers.add_parser("suggest-blocks", help="Suggest or execute guarded source IP blocks")
blocks.add_argument("--logs", required=True, help="Path to syslog JSONL or key/value log file")
blocks.add_argument("--min-events", type=int, default=3)
blocks.add_argument("--min-score", type=int, default=7)
blocks.add_argument("--allowlist", default=None, help="Comma-separated IPs/CIDRs never to block")
blocks.add_argument("--execute", action="store_true", help="Actually call the FortiGate quarantine API")
blocks.add_argument("--expiry-minutes", type=int, default=60)
blocks.set_defaults(func=suggest_blocks)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except Exception as exc:
print(f"fgai: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

34
src/fgai/llm.py Normal file
View File

@@ -0,0 +1,34 @@
from __future__ import annotations
import json
import os
from urllib import request
from .models import BlockCandidate, Finding
def ollama_summary(findings: list[Finding], candidates: list[BlockCandidate], model: str | None = None) -> str:
host = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434").rstrip("/")
selected_model = model or os.getenv("OLLAMA_MODEL", "llama3.3")
prompt = {
"findings": [finding.__dict__ for finding in findings],
"block_candidates": [
{"src_ip": candidate.src_ip, "score": candidate.score, "reasons": candidate.reasons}
for candidate in candidates
],
}
body = json.dumps(
{
"model": selected_model,
"stream": False,
"prompt": (
"You are a local FortiGate security analyst. Summarize these policy findings "
"and UTM block candidates. Be concise, include risk, likely cause, and next action. "
f"Data: {json.dumps(prompt)}"
),
}
).encode("utf-8")
req = request.Request(f"{host}/api/generate", data=body, method="POST", headers={"Content-Type": "application/json"})
with request.urlopen(req, timeout=60) as response:
data = json.loads(response.read().decode("utf-8"))
return str(data.get("response", "")).strip()

119
src/fgai/logs.py Normal file
View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import json
import shlex
from collections.abc import Iterable
from pathlib import Path
from .models import LogEvent
UTM_SUBTYPES = {
"ips",
"virus",
"av",
"anomaly",
"ddos",
"dos",
"webfilter",
"app-ctrl",
"application",
"waf",
"dns",
}
THREAT_ACTIONS = {
"blocked",
"block",
"dropped",
"drop",
"reset",
"reset-client",
"reset-server",
"detected",
"deny",
"quarantine",
}
SEVERITY_SCORE = {
"emergency": 6,
"alert": 6,
"critical": 5,
"high": 4,
"warning": 3,
"medium": 3,
"notice": 2,
"low": 1,
"information": 1,
"info": 1,
}
def parse_log_line(line: str) -> LogEvent:
stripped = line.strip()
if not stripped:
return LogEvent(raw=line, fields={})
if stripped.startswith("{"):
try:
data = json.loads(stripped)
except json.JSONDecodeError:
data = {}
else:
return LogEvent(raw=line, fields={str(k).lower(): str(v) for k, v in data.items()})
fields: dict[str, str] = {}
try:
parts = shlex.split(stripped)
except ValueError:
parts = stripped.split()
for part in parts:
if "=" not in part:
continue
key, value = part.split("=", 1)
fields[key.strip().lower()] = value.strip().strip('"')
return LogEvent(raw=line, fields=fields)
def read_events(path: str | Path) -> list[LogEvent]:
with Path(path).open("r", encoding="utf-8", errors="replace") as handle:
return [event for line in handle if (event := parse_log_line(line)).fields]
def is_utm_event(event: LogEvent) -> bool:
subtype = event.subtype
event_type = event.fields.get("type", "").lower()
return event_type == "utm" or subtype in UTM_SUBTYPES
def event_score(event: LogEvent) -> int:
score = SEVERITY_SCORE.get(event.severity, 0)
if is_utm_event(event):
score += 2
if event.action in THREAT_ACTIONS:
score += 2
if event.fields.get("attack") or event.fields.get("attackid") or event.fields.get("signature"):
score += 1
return score
def summarize_events(events: Iterable[LogEvent]) -> dict[str, int]:
summary = {
"total": 0,
"utm": 0,
"threat_actions": 0,
"with_src_ip": 0,
"critical_or_high": 0,
}
for event in events:
summary["total"] += 1
if is_utm_event(event):
summary["utm"] += 1
if event.action in THREAT_ACTIONS:
summary["threat_actions"] += 1
if event.src_ip:
summary["with_src_ip"] += 1
if event.severity in {"critical", "high", "alert", "emergency"}:
summary["critical_or_high"] += 1
return summary

109
src/fgai/mitigation.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import ipaddress
import os
from collections import defaultdict
from dataclasses import dataclass
from typing import Iterable
from urllib import error, request
import json
import ssl
from .logs import event_score, is_utm_event
from .models import BlockCandidate, LogEvent
def parse_allowlist(value: str | None = None) -> list[ipaddress._BaseNetwork]:
raw = value if value is not None else os.getenv("FGAI_ALLOWLIST", "")
networks: list[ipaddress._BaseNetwork] = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
networks.append(ipaddress.ip_network(item, strict=False))
return networks
def is_blockable_public_ip(ip: str, allowlist: Iterable[ipaddress._BaseNetwork] = ()) -> bool:
try:
address = ipaddress.ip_address(ip)
except ValueError:
return False
if not address.is_global:
return False
return not any(address in network for network in allowlist)
def suggest_block_candidates(
events: Iterable[LogEvent],
*,
min_events: int = 3,
min_score: int = 7,
allowlist: Iterable[ipaddress._BaseNetwork] = (),
) -> list[BlockCandidate]:
grouped: dict[str, list[LogEvent]] = defaultdict(list)
for event in events:
if not event.src_ip or not is_utm_event(event):
continue
if not is_blockable_public_ip(event.src_ip, allowlist):
continue
if event_score(event) > 0:
grouped[event.src_ip].append(event)
candidates: list[BlockCandidate] = []
for src_ip, ip_events in grouped.items():
score = sum(event_score(event) for event in ip_events)
if len(ip_events) < min_events and score < min_score:
continue
reasons = sorted(
{
f"{event.subtype or 'utm'}:{event.action or 'observed'}:{event.severity or 'unknown'}"
for event in ip_events
}
)
candidates.append(BlockCandidate(src_ip, score, reasons, ip_events[-5:]))
return sorted(candidates, key=lambda candidate: candidate.score, reverse=True)
@dataclass(frozen=True)
class FortiGateClient:
host: str
api_token: str
verify_tls: bool = True
@classmethod
def from_env(cls) -> "FortiGateClient":
host = os.environ["FORTIGATE_HOST"]
token = os.environ["FORTIGATE_API_TOKEN"]
verify_tls = os.getenv("FORTIGATE_VERIFY_TLS", "true").lower() not in {"0", "false", "no"}
return cls(host=host, api_token=token, verify_tls=verify_tls)
def quarantine_ip(self, src_ip: str, expiry_minutes: int, reason: str) -> str:
payload = {
"ip_address": src_ip,
"expiry": expiry_minutes,
"source": "fgai-local-agent",
"reason": reason,
}
url = f"https://{self.host}/api/v2/monitor/user/banned/create"
body = json.dumps(payload).encode("utf-8")
req = request.Request(
url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
context = None if self.verify_tls else ssl._create_unverified_context()
try:
with request.urlopen(req, timeout=15, context=context) as response:
return response.read().decode("utf-8", errors="replace")
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"FortiGate API returned HTTP {exc.code}: {detail}") from exc
except error.URLError as exc:
raise RuntimeError(f"Could not reach FortiGate API: {exc.reason}") from exc

54
src/fgai/models.py Normal file
View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class LogEvent:
raw: str
fields: dict[str, str]
@property
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 subtype(self) -> str:
return self.fields.get("subtype", "").lower()
@property
def action(self) -> str:
return self.fields.get("action", "").lower()
@property
def severity(self) -> str:
return self.fields.get("severity", self.fields.get("level", "")).lower()
@dataclass(frozen=True)
class PolicyRule:
policy_id: str
settings: dict[str, list[str]] = field(default_factory=dict)
def values(self, key: str) -> list[str]:
return self.settings.get(key, [])
def first(self, key: str, default: str = "") -> str:
values = self.values(key)
return values[0] if values else default
@dataclass(frozen=True)
class Finding:
severity: str
title: str
detail: str
reference: str | None = None
@dataclass(frozen=True)
class BlockCandidate:
src_ip: str
score: int
reasons: list[str]
sample_events: list[LogEvent]

106
src/fgai/policies.py Normal file
View File

@@ -0,0 +1,106 @@
from __future__ import annotations
import shlex
from pathlib import Path
from .models import Finding, PolicyRule
ANY_VALUES = {"all", "any", "0.0.0.0/0"}
UTM_KEYS = {"ips-sensor", "av-profile", "webfilter-profile", "application-list", "waf-profile", "dnsfilter-profile"}
def _parse_set_values(line: str) -> tuple[str, list[str]] | None:
try:
parts = shlex.split(line)
except ValueError:
parts = line.split()
if len(parts) < 3 or parts[0] != "set":
return None
return parts[1], parts[2:]
def parse_policy_config(text: str) -> list[PolicyRule]:
in_policy = False
current_id: str | None = None
current_settings: dict[str, list[str]] = {}
policies: list[PolicyRule] = []
for raw_line in text.splitlines():
line = raw_line.strip()
if line == "config firewall policy":
in_policy = True
continue
if in_policy and line == "end":
if current_id is not None:
policies.append(PolicyRule(current_id, current_settings))
break
if not in_policy:
continue
if line.startswith("edit "):
if current_id is not None:
policies.append(PolicyRule(current_id, current_settings))
current_id = line.split(maxsplit=1)[1].strip('"')
current_settings = {}
continue
if line == "next":
if current_id is not None:
policies.append(PolicyRule(current_id, current_settings))
current_id = None
current_settings = {}
continue
parsed = _parse_set_values(line)
if current_id is not None and parsed:
key, values = parsed
current_settings[key] = values
return policies
def read_policies(path: str | Path) -> list[PolicyRule]:
return parse_policy_config(Path(path).read_text(encoding="utf-8", errors="replace"))
def audit_policies(policies: list[PolicyRule]) -> list[Finding]:
findings: list[Finding] = []
for policy in policies:
if policy.first("status", "enable") == "disable":
continue
action = policy.first("action")
srcaddr = {value.lower() for value in policy.values("srcaddr")}
dstaddr = {value.lower() for value in policy.values("dstaddr")}
service = {value.lower() for value in policy.values("service")}
policy_ref = f"policy {policy.policy_id}"
if action == "accept" and srcaddr & ANY_VALUES and dstaddr & ANY_VALUES and service & ANY_VALUES:
findings.append(
Finding(
severity="critical",
title="Broad allow policy",
detail="Accepts traffic from any source to any destination for any service.",
reference=policy_ref,
)
)
if action == "accept" and policy.first("logtraffic", "disable") in {"disable", "utm"}:
findings.append(
Finding(
severity="medium",
title="Insufficient traffic logging",
detail="Accepted traffic is not fully logged, reducing investigation value.",
reference=policy_ref,
)
)
has_utm = policy.first("utm-status") == "enable" or any(policy.values(key) for key in UTM_KEYS)
if action == "accept" and not has_utm:
findings.append(
Finding(
severity="high",
title="Accepted traffic lacks UTM inspection",
detail="No IPS, AV, web filter, application, DNS, or WAF profile is attached.",
reference=policy_ref,
)
)
return findings