This commit is contained in:
larssand
2026-06-18 22:38:47 +02:00
parent b4ad932c91
commit 5650973e50
9 changed files with 700 additions and 28 deletions

View File

@@ -21,6 +21,12 @@ Or use the helper script, which creates/uses `.venv` automatically and runs `pip
./start.sh stop ./start.sh stop
``` ```
`./start.sh` starts three local background processes:
- UDP syslog listener writing `logs/fg_syslog.jsonl`
- Continuous monitor writing `state/fgai-status.json`
- Local dashboard at `http://127.0.0.1:8088`
The script activates `.venv` inside the script process. If you also want your current shell prompt to show the venv, run: The script activates `.venv` inside the script process. If you also want your current shell prompt to show the venv, run:
```bash ```bash
@@ -39,6 +45,19 @@ Analyze local logs:
fgai analyze-logs --logs logs/fg_syslog.jsonl fgai analyze-logs --logs logs/fg_syslog.jsonl
``` ```
Open the live UI after `./start.sh`:
```bash
xdg-open http://127.0.0.1:8088
```
Score likely traffic anomalies:
```bash
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
```
Listen for FortiGate syslog locally: Listen for FortiGate syslog locally:
```bash ```bash

161
src/fgai/anomaly.py Normal file
View File

@@ -0,0 +1,161 @@
from __future__ import annotations
import ipaddress
from collections import Counter, defaultdict
from statistics import mean, pstdev
from .logs import THREAT_ACTIONS, event_score, is_utm_event
from .models import AnomalyFinding, LogEvent
def _as_int(value: str | None) -> int:
if not value:
return 0
try:
return int(float(value))
except ValueError:
return 0
def _severity(score: int) -> str:
if score >= 80:
return "critical"
if score >= 60:
return "high"
if score >= 35:
return "medium"
return "low"
def _confidence(event_count: int, reason_count: int) -> str:
if event_count >= 20 and reason_count >= 3:
return "high"
if event_count >= 5 and reason_count >= 2:
return "medium"
return "low"
def _is_public_ip(value: str) -> bool:
try:
return ipaddress.ip_address(value).is_global
except ValueError:
return False
def detect_source_anomalies(events: list[LogEvent], *, limit: int = 20) -> list[AnomalyFinding]:
by_src: dict[str, list[LogEvent]] = defaultdict(list)
for event in events:
if event.src_ip:
by_src[event.src_ip].append(event)
if not by_src:
return []
event_counts = [len(src_events) for src_events in by_src.values()]
distinct_dst_counts = [
len({event.fields.get("dstip") for event in src_events if event.fields.get("dstip")})
for src_events in by_src.values()
]
byte_totals = [
sum(_as_int(event.fields.get("sentbyte")) + _as_int(event.fields.get("rcvdbyte")) for event in src_events)
for src_events in by_src.values()
]
avg_events = mean(event_counts)
std_events = pstdev(event_counts) or 1.0
avg_dst = mean(distinct_dst_counts)
std_dst = pstdev(distinct_dst_counts) or 1.0
avg_bytes = mean(byte_totals)
std_bytes = pstdev(byte_totals) or 1.0
findings: list[AnomalyFinding] = []
for src_ip, src_events in by_src.items():
event_count = len(src_events)
distinct_dst = len({event.fields.get("dstip") for event in src_events if event.fields.get("dstip")})
distinct_services = len({event.fields.get("service") for event in src_events if event.fields.get("service")})
total_bytes = sum(_as_int(event.fields.get("sentbyte")) + _as_int(event.fields.get("rcvdbyte")) for event in src_events)
deny_count = sum(1 for event in src_events if event.action in THREAT_ACTIONS)
utm_count = sum(1 for event in src_events if is_utm_event(event))
high_severity_count = sum(1 for event in src_events if event.severity in {"critical", "high", "alert", "emergency"})
policies = {event.fields.get("policyid") for event in src_events if event.fields.get("policyid")}
reasons: list[str] = []
score = 0
event_z = (event_count - avg_events) / std_events
if event_count >= 25 and event_z >= 2:
points = min(25, 10 + int(event_z * 5))
score += points
reasons.append(f"unusually high event volume for source ({event_count} events, z={event_z:.1f})")
dst_z = (distinct_dst - avg_dst) / std_dst
if distinct_dst >= 10 and dst_z >= 2:
points = min(25, 10 + int(dst_z * 5))
score += points
reasons.append(f"source contacted unusually many destinations ({distinct_dst}, z={dst_z:.1f})")
byte_z = (total_bytes - avg_bytes) / std_bytes
if total_bytes >= 50_000_000 and byte_z >= 2:
points = min(20, 8 + int(byte_z * 4))
score += points
reasons.append(f"unusually high byte volume ({total_bytes} bytes, z={byte_z:.1f})")
if event_count >= 5:
deny_rate = deny_count / event_count
if deny_count >= 10 and deny_rate >= 0.5:
score += min(20, 8 + int(deny_rate * 12))
reasons.append(f"high deny/threat-action rate ({deny_count}/{event_count})")
if utm_count:
utm_score = sum(event_score(event) for event in src_events if is_utm_event(event))
points = min(35, 5 + utm_score)
score += points
reasons.append(f"UTM/security detections observed ({utm_count} events)")
if high_severity_count:
score += min(20, high_severity_count * 8)
reasons.append(f"high or critical severity events observed ({high_severity_count})")
if distinct_services >= 8 and event_count >= 10:
score += min(15, distinct_services)
reasons.append(f"many distinct services used ({distinct_services})")
if _is_public_ip(src_ip) and (utm_count or deny_count >= 10):
score += 10
reasons.append("public source with repeated security-relevant events")
if not reasons:
continue
score = min(score, 100)
findings.append(
AnomalyFinding(
subject=src_ip,
score=score,
severity=_severity(score),
confidence=_confidence(event_count, len(reasons)),
reasons=reasons,
evidence={
"events": event_count,
"distinct_destinations": distinct_dst,
"distinct_services": distinct_services,
"deny_or_threat_actions": deny_count,
"utm_events": utm_count,
"high_severity_events": high_severity_count,
"total_bytes": total_bytes,
"policy_count": len(policies),
},
)
)
return sorted(findings, key=lambda finding: finding.score, reverse=True)[:limit]
def anomaly_summary(findings: list[AnomalyFinding]) -> dict[str, int]:
counts = Counter(finding.severity for finding in findings)
return {
"total": len(findings),
"critical": counts["critical"],
"high": counts["high"],
"medium": counts["medium"],
"low": counts["low"],
}

View File

@@ -5,11 +5,14 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
from .anomaly import anomaly_summary, detect_source_anomalies
from .dashboard import serve_dashboard
from .llm import ollama_summary from .llm import ollama_summary
from .logs import local_in_failures, read_events, summarize_events, top_field_values from .logs import local_in_failures, read_events, summarize_events, top_field_values
from .mitigation import FortiGateClient, parse_allowlist, suggest_block_candidates from .mitigation import FortiGateClient, parse_allowlist, suggest_block_candidates
from .policies import audit_policies, read_policies from .policies import audit_policies, read_policies
from .syslog_server import listen_udp_syslog from .syslog_server import listen_udp_syslog
from .monitor import monitor_loop
def _print_json(data: object) -> None: def _print_json(data: object) -> None:
@@ -24,8 +27,10 @@ def analyze_logs(args: argparse.Namespace) -> int:
min_score=args.min_score, min_score=args.min_score,
allowlist=parse_allowlist(args.allowlist), allowlist=parse_allowlist(args.allowlist),
) )
anomalies = detect_source_anomalies(events, limit=args.anomaly_limit)
analysis = { analysis = {
"summary": summarize_events(events), "summary": summarize_events(events),
"anomaly_summary": anomaly_summary(anomalies),
"diagnostics": { "diagnostics": {
"top_source_ips": top_field_values(events, "srcip", limit=10), "top_source_ips": top_field_values(events, "srcip", limit=10),
"top_services": top_field_values(events, "service", limit=10), "top_services": top_field_values(events, "service", limit=10),
@@ -37,6 +42,17 @@ def analyze_logs(args: argparse.Namespace) -> int:
{"src_ip": candidate.src_ip, "score": candidate.score, "reasons": candidate.reasons} {"src_ip": candidate.src_ip, "score": candidate.score, "reasons": candidate.reasons}
for candidate in candidates for candidate in candidates
], ],
"anomalies": [
{
"subject": finding.subject,
"score": finding.score,
"severity": finding.severity,
"confidence": finding.confidence,
"reasons": finding.reasons,
"evidence": finding.evidence,
}
for finding in anomalies
],
} }
_print_json(analysis) _print_json(analysis)
if args.llm: if args.llm:
@@ -78,6 +94,31 @@ def suggest_blocks(args: argparse.Namespace) -> int:
return 0 return 0
def detect_anomalies(args: argparse.Namespace) -> int:
events = read_events(args.logs)
anomalies = detect_source_anomalies(events, limit=args.limit)
output = {
"summary": anomaly_summary(anomalies),
"anomalies": [
{
"subject": finding.subject,
"score": finding.score,
"severity": finding.severity,
"confidence": finding.confidence,
"reasons": finding.reasons,
"evidence": finding.evidence,
}
for finding in anomalies
if finding.score >= args.min_score
],
}
_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: def test_connection(args: argparse.Namespace) -> int:
client = FortiGateClient.from_env() client = FortiGateClient.from_env()
status = client.system_status() status = client.system_status()
@@ -105,6 +146,22 @@ def listen_syslog(args: argparse.Namespace) -> int:
return 0 return 0
def run_monitor(args: argparse.Namespace) -> int:
monitor_loop(
args.logs,
args.output,
policy_path=args.policies,
interval=args.interval,
anomaly_limit=args.anomaly_limit,
)
return 0
def run_dashboard(args: argparse.Namespace) -> int:
serve_dashboard(args.host, args.port, args.status_file, image_dir=args.image_dir)
return 0
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Local FortiGate AI/ML inspection tool") parser = argparse.ArgumentParser(description="Local FortiGate AI/ML inspection tool")
subparsers = parser.add_subparsers(required=True) subparsers = parser.add_subparsers(required=True)
@@ -117,8 +174,18 @@ def build_parser() -> argparse.ArgumentParser:
logs.add_argument("--llm", action="store_true", help="Ask local Ollama to summarize results") 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.add_argument("--model", default=None, help="Ollama model name")
logs.add_argument("--llm-timeout", type=int, default=None, help="Ollama request timeout in seconds") logs.add_argument("--llm-timeout", type=int, default=None, help="Ollama request timeout in seconds")
logs.add_argument("--anomaly-limit", type=int, default=20, help="Maximum anomaly findings to include")
logs.set_defaults(func=analyze_logs) logs.set_defaults(func=analyze_logs)
anomalies = subparsers.add_parser("detect-anomalies", help="Score likely traffic anomalies by source IP")
anomalies.add_argument("--logs", required=True, help="Path to syslog JSONL or key/value log file")
anomalies.add_argument("--limit", type=int, default=20, help="Maximum anomaly findings to include")
anomalies.add_argument("--min-score", type=int, default=1, help="Minimum anomaly score to output")
anomalies.add_argument("--llm", action="store_true", help="Ask local Ollama to summarize results")
anomalies.add_argument("--model", default=None, help="Ollama model name")
anomalies.add_argument("--llm-timeout", type=int, default=None, help="Ollama request timeout in seconds")
anomalies.set_defaults(func=detect_anomalies)
policies = subparsers.add_parser("audit-policies", help="Audit FortiOS firewall policy config") 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("--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("--llm", action="store_true", help="Ask local Ollama to summarize results")
@@ -149,6 +216,21 @@ def build_parser() -> argparse.ArgumentParser:
listener.add_argument("--quiet", action="store_true", help="Do not print each received syslog message") listener.add_argument("--quiet", action="store_true", help="Do not print each received syslog message")
listener.set_defaults(func=listen_syslog) listener.set_defaults(func=listen_syslog)
monitor = subparsers.add_parser("monitor", help="Continuously analyze logs and write dashboard status JSON")
monitor.add_argument("--logs", required=True, help="Path to syslog JSONL or key/value log file")
monitor.add_argument("--output", default="state/fgai-status.json", help="Status JSON written for dashboard")
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.set_defaults(func=run_monitor)
dashboard = subparsers.add_parser("dashboard", help="Serve local fgAI dashboard")
dashboard.add_argument("--host", default="127.0.0.1", help="Dashboard bind address")
dashboard.add_argument("--port", type=int, default=8088, help="Dashboard TCP port")
dashboard.add_argument("--status-file", default="state/fgai-status.json", help="Status JSON produced by monitor")
dashboard.add_argument("--image-dir", default="images", help="Directory containing dashboard images")
dashboard.set_defaults(func=run_dashboard)
return parser return parser

154
src/fgai/dashboard.py Normal file
View File

@@ -0,0 +1,154 @@
from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>fgAI Monitor</title>
<style>
:root { color-scheme: light; font-family: Arial, sans-serif; background: #f5f7f9; color: #16202a; }
body { margin: 0; }
header { background: #102032; color: white; padding: 18px 24px; }
h1 { margin: 0; font-size: 22px; }
main { padding: 18px; max-width: 1320px; margin: 0 auto; }
.hero { display: grid; grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.1fr); gap: 14px; align-items: stretch; }
.hero img { width: 100%; height: 100%; max-height: 360px; object-fit: cover; border-radius: 6px; border: 1px solid #1f3b57; background: #061322; }
.hero .panel { margin-bottom: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.panel { background: white; border: 1px solid #d9e0e7; border-radius: 6px; padding: 14px; margin-bottom: 14px; }
.metric { font-size: 28px; font-weight: 700; }
.label { color: #536170; font-size: 13px; margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { border-bottom: 1px solid #e4e9ee; padding: 8px; text-align: left; vertical-align: top; }
th { color: #536170; font-weight: 600; }
.sev-critical { color: #b00020; font-weight: 700; }
.sev-high { color: #b54708; font-weight: 700; }
.sev-medium { color: #8a6d00; font-weight: 700; }
.sev-low { color: #345995; font-weight: 700; }
.muted { color: #697789; }
code { background: #eef2f6; padding: 2px 4px; border-radius: 4px; }
@media (max-width: 860px) { .hero { grid-template-columns: 1fr; } .hero img { max-height: 240px; } }
</style>
</head>
<body>
<header><h1>fgAI Monitor</h1><div id="stamp" class="muted"></div></header>
<main>
<section class="hero">
<img src="/images/FGinspectionagent.png" alt="FortiGate AI/ML Analyzer">
<div>
<section class="grid" id="metrics"></section>
<section class="panel"><h2>Live Status</h2><div id="liveStatus" class="muted">Waiting for monitor data.</div></section>
</div>
</section>
<section class="panel"><h2>Anomalies</h2><div id="anomalies"></div></section>
<section class="panel"><h2>Block Candidates</h2><div id="blocks"></div></section>
<section class="panel"><h2>Policy Findings</h2><div id="policies"></div></section>
<section class="panel"><h2>Diagnostics</h2><div id="diagnostics"></div></section>
</main>
<script>
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function metric(label, value) {
return `<div class="panel"><div class="metric">${esc(value)}</div><div class="label">${esc(label)}</div></div>`;
}
function table(rows, columns) {
if (!rows || rows.length === 0) return '<p class="muted">No data.</p>';
const head = columns.map(c => `<th>${esc(c.label)}</th>`).join('');
const body = rows.map(row => `<tr>${columns.map(c => `<td>${c.render ? c.render(row) : esc(row[c.key])}</td>`).join('')}</tr>`).join('');
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
async function refresh() {
const res = await fetch('/api/status', {cache: 'no-store'});
const data = await res.json();
const s = data.summary || {};
const a = data.anomaly_summary || {};
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),
metric('UTM events', s.utm || 0),
metric('Threat actions', s.threat_actions || 0),
metric('Anomalies high+', (a.high || 0) + (a.critical || 0))
].join('');
document.getElementById('liveStatus').innerHTML = [
`Log file: <code>${esc(data.log_path || '')}</code>`,
`Policy file: <code>${esc(data.policy_path || 'none')}</code>`,
`Critical anomalies: ${esc((a.critical || 0))}`,
`High anomalies: ${esc((a.high || 0))}`
].join('<br>');
document.getElementById('anomalies').innerHTML = table(data.anomalies || [], [
{label:'Source', key:'subject'},
{label:'Score', key:'score'},
{label:'Severity', render:r => `<span class="sev-${esc(r.severity)}">${esc(r.severity)}</span>`},
{label:'Confidence', key:'confidence'},
{label:'Reasons', render:r => esc((r.reasons || []).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('; '))}
]);
document.getElementById('policies').innerHTML = table(data.policy_findings || [], [
{label:'Severity', render:r => `<span class="sev-${esc(r.severity)}">${esc(r.severity)}</span>`},
{label:'Reference', key:'reference'},
{label:'Title', key:'title'},
{label:'Detail', key:'detail'}
]);
const d = data.diagnostics || {};
document.getElementById('diagnostics').innerHTML =
'<h3>Top Sources</h3>' + table(d.top_source_ips || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'<h3>Top Services</h3>' + table(d.top_services || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
'<h3>Local-in Failures</h3>' + table(d.local_in_failures || [], [{label:'Source', key:'src_ip'}, {label:'Service', key:'service'}, {label:'Policy', key:'policy'}, {label:'Count', key:'count'}]);
}
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
"""
def serve_dashboard(host: str, port: int, status_file: str, *, image_dir: str | None = None) -> None:
status_path = Path(status_file)
image_root = Path(image_dir) if image_dir else None
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/":
self._send(200, "text/html; charset=utf-8", HTML.encode("utf-8"))
return
if self.path == "/api/status":
if status_path.exists():
body = status_path.read_bytes()
else:
body = json.dumps({"summary": {}, "anomalies": [], "block_candidates": []}).encode("utf-8")
self._send(200, "application/json", body)
return
if self.path.startswith("/images/") and image_root:
image_path = image_root / Path(self.path).name
if image_path.exists() and image_path.is_file():
content_type = "image/png" if image_path.suffix.lower() == ".png" else "application/octet-stream"
self._send(200, content_type, image_path.read_bytes())
return
self._send(404, "text/plain; charset=utf-8", b"not found")
def log_message(self, format: str, *args: object) -> None:
return
def _send(self, status: int, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer((host, port), Handler)
print(f"Dashboard listening on http://{host}:{port}")
print(f"Reading status from {status_path}")
server.serve_forever()

View File

@@ -52,3 +52,13 @@ class BlockCandidate:
score: int score: int
reasons: list[str] reasons: list[str]
sample_events: list[LogEvent] sample_events: list[LogEvent]
@dataclass(frozen=True)
class AnomalyFinding:
subject: str
score: int
severity: str
confidence: str
reasons: list[str]
evidence: dict[str, int | str | float]

92
src/fgai/monitor.py Normal file
View File

@@ -0,0 +1,92 @@
from __future__ import annotations
import json
import time
from pathlib import Path
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
def build_status(
log_path: str,
*,
policy_path: str | None = None,
min_block_events: int = 3,
min_block_score: int = 7,
anomaly_limit: int = 20,
) -> dict[str, object]:
events = read_events(log_path) if Path(log_path).exists() else []
anomalies = detect_source_anomalies(events, limit=anomaly_limit)
block_candidates = suggest_block_candidates(
events,
min_events=min_block_events,
min_score=min_block_score,
allowlist=parse_allowlist(),
)
policy_findings: list[dict[str, str | None]] = []
policy_error: str | None = None
if policy_path and Path(policy_path).exists():
try:
policy_findings = [finding.__dict__ for finding in audit_policies(read_policies(policy_path))]
except Exception as exc:
policy_error = str(exc)
return {
"generated_at": int(time.time()),
"log_path": log_path,
"policy_path": policy_path,
"summary": summarize_events(events),
"anomaly_summary": anomaly_summary(anomalies),
"diagnostics": {
"top_source_ips": top_field_values(events, "srcip", limit=10),
"top_services": top_field_values(events, "service", limit=10),
"top_actions": top_field_values(events, "action", limit=10),
"top_subtypes": top_field_values(events, "subtype", limit=10),
"local_in_failures": local_in_failures(events, limit=10),
},
"anomalies": [
{
"subject": finding.subject,
"score": finding.score,
"severity": finding.severity,
"confidence": finding.confidence,
"reasons": finding.reasons,
"evidence": finding.evidence,
}
for finding in anomalies
],
"block_candidates": [
{"src_ip": candidate.src_ip, "score": candidate.score, "reasons": candidate.reasons}
for candidate in block_candidates
],
"policy_findings": policy_findings,
"policy_error": policy_error,
}
def write_status(status: dict[str, object], output: str) -> None:
output_path = Path(output)
output_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = output_path.with_suffix(f"{output_path.suffix}.tmp")
tmp_path.write_text(json.dumps(status, indent=2, sort_keys=True), encoding="utf-8")
tmp_path.replace(output_path)
def monitor_loop(
log_path: str,
output: str,
*,
policy_path: str | None = None,
interval: int = 10,
anomaly_limit: int = 20,
) -> None:
print(f"Monitoring {log_path}")
print(f"Writing status to {output}")
while True:
status = build_status(log_path, policy_path=policy_path, anomaly_limit=anomaly_limit)
write_status(status, output)
time.sleep(interval)

130
start.sh
View File

@@ -7,7 +7,16 @@ PORT="${FGAI_SYSLOG_PORT:-5514}"
HOST="${FGAI_SYSLOG_HOST:-0.0.0.0}" HOST="${FGAI_SYSLOG_HOST:-0.0.0.0}"
LOG_FILE="${FGAI_SYSLOG_FILE:-$ROOT_DIR/logs/fg_syslog.jsonl}" LOG_FILE="${FGAI_SYSLOG_FILE:-$ROOT_DIR/logs/fg_syslog.jsonl}"
LISTENER_LOG="${FGAI_LISTENER_LOG:-$ROOT_DIR/logs/fgai-listener.log}" LISTENER_LOG="${FGAI_LISTENER_LOG:-$ROOT_DIR/logs/fgai-listener.log}"
PID_FILE="${FGAI_PID_FILE:-$ROOT_DIR/run/fgai-listener.pid}" 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}"
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}"
MONITOR_PID_FILE="${FGAI_MONITOR_PID_FILE:-$ROOT_DIR/run/fgai-monitor.pid}"
DASHBOARD_PID_FILE="${FGAI_DASHBOARD_PID_FILE:-$ROOT_DIR/run/fgai-dashboard.pid}"
MONITOR_LOG="${FGAI_MONITOR_LOG:-$ROOT_DIR/logs/fgai-monitor.log}"
DASHBOARD_LOG="${FGAI_DASHBOARD_LOG:-$ROOT_DIR/logs/fgai-dashboard.log}"
usage() { usage() {
printf 'Usage: %s [start|stop|restart|status|tail|analyze|install]\n' "$0" printf 'Usage: %s [start|stop|restart|status|tail|analyze|install]\n' "$0"
@@ -17,6 +26,7 @@ usage() {
printf ' FGAI_SYSLOG_HOST=%s\n' "$HOST" printf ' FGAI_SYSLOG_HOST=%s\n' "$HOST"
printf ' FGAI_SYSLOG_FILE=%s\n' "$LOG_FILE" printf ' FGAI_SYSLOG_FILE=%s\n' "$LOG_FILE"
printf ' FGAI_LISTENER_LOG=%s\n' "$LISTENER_LOG" printf ' FGAI_LISTENER_LOG=%s\n' "$LISTENER_LOG"
printf ' FGAI_DASHBOARD_PORT=%s\n' "$DASHBOARD_PORT"
} }
activate_venv_for_script() { activate_venv_for_script() {
@@ -38,8 +48,8 @@ install_deps() {
python -m pip install -e "$ROOT_DIR" python -m pip install -e "$ROOT_DIR"
} }
is_running() { is_pid_running() {
[ -f "$PID_FILE" ] && ps -p "$(cat "$PID_FILE")" >/dev/null 2>&1 [ -f "$1" ] && ps -p "$(cat "$1")" >/dev/null 2>&1
} }
needs_privileged_port() { needs_privileged_port() {
@@ -56,11 +66,11 @@ listener_command() {
start_listener() { start_listener() {
install_deps install_deps
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$LISTENER_LOG")" "$(dirname "$PID_FILE")" mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$LISTENER_LOG")" "$(dirname "$LISTENER_PID_FILE")" "$(dirname "$STATE_FILE")"
touch "$LOG_FILE" touch "$LOG_FILE"
if is_running; then if is_pid_running "$LISTENER_PID_FILE"; then
printf 'fgAI syslog listener already running, pid %s\n' "$(cat "$PID_FILE")" printf 'fgAI syslog listener already running, pid %s\n' "$(cat "$LISTENER_PID_FILE")"
return 0 return 0
fi fi
@@ -78,57 +88,121 @@ start_listener() {
--output "$LOG_FILE" \ --output "$LOG_FILE" \
--quiet > "$LISTENER_LOG" 2>&1 & --quiet > "$LISTENER_LOG" 2>&1 &
fi fi
printf '%s\n' "$!" > "$PID_FILE" printf '%s\n' "$!" > "$LISTENER_PID_FILE"
printf 'Started fgAI syslog listener, pid %s\n' "$(cat "$PID_FILE")" printf 'Started fgAI syslog listener, pid %s\n' "$(cat "$LISTENER_PID_FILE")"
printf 'Input: udp://%s:%s\n' "$HOST" "$PORT" printf 'Input: udp://%s:%s\n' "$HOST" "$PORT"
printf 'Syslog file: %s\n' "$LOG_FILE" printf 'Syslog file: %s\n' "$LOG_FILE"
printf 'Process log: %s\n' "$LISTENER_LOG" printf 'Process log: %s\n' "$LISTENER_LOG"
} }
stop_listener() { start_monitor() {
if ! is_running; then install_deps
printf 'fgAI syslog listener is not running\n' mkdir -p "$(dirname "$STATE_FILE")" "$(dirname "$MONITOR_LOG")" "$(dirname "$MONITOR_PID_FILE")"
rm -f "$PID_FILE"
if is_pid_running "$MONITOR_PID_FILE"; then
printf 'fgAI monitor already running, pid %s\n' "$(cat "$MONITOR_PID_FILE")"
return 0 return 0
fi fi
if ! kill "$(cat "$PID_FILE")" 2>/dev/null; then monitor_args=(monitor --logs "$LOG_FILE" --output "$STATE_FILE" --interval "$MONITOR_INTERVAL")
sudo kill "$(cat "$PID_FILE")" if [ -f "$POLICY_FILE" ]; then
monitor_args+=(--policies "$POLICY_FILE")
fi fi
rm -f "$PID_FILE"
printf 'Stopped fgAI syslog listener\n' nohup "$VENV_DIR/bin/fgai" "${monitor_args[@]}" > "$MONITOR_LOG" 2>&1 &
printf '%s\n' "$!" > "$MONITOR_PID_FILE"
printf 'Started fgAI monitor, pid %s\n' "$(cat "$MONITOR_PID_FILE")"
printf 'Status file: %s\n' "$STATE_FILE"
} }
status_listener() { start_dashboard() {
if is_running; then install_deps
printf 'fgAI syslog listener running, pid %s\n' "$(cat "$PID_FILE")" mkdir -p "$(dirname "$DASHBOARD_LOG")" "$(dirname "$DASHBOARD_PID_FILE")"
if is_pid_running "$DASHBOARD_PID_FILE"; then
printf 'fgAI dashboard already running, pid %s\n' "$(cat "$DASHBOARD_PID_FILE")"
return 0
fi
nohup "$VENV_DIR/bin/fgai" dashboard \
--host "$DASHBOARD_HOST" \
--port "$DASHBOARD_PORT" \
--status-file "$STATE_FILE" \
--image-dir "$ROOT_DIR/images" > "$DASHBOARD_LOG" 2>&1 &
printf '%s\n' "$!" > "$DASHBOARD_PID_FILE"
printf 'Started fgAI dashboard, pid %s\n' "$(cat "$DASHBOARD_PID_FILE")"
printf 'Dashboard: http://%s:%s\n' "$DASHBOARD_HOST" "$DASHBOARD_PORT"
}
start_all() {
start_listener
start_monitor
start_dashboard
}
stop_pid() {
label="$1"
pid_file="$2"
if ! is_pid_running "$pid_file"; then
printf '%s is not running\n' "$label"
rm -f "$pid_file"
return 0
fi
if ! kill "$(cat "$pid_file")" 2>/dev/null; then
sudo kill "$(cat "$pid_file")"
fi
rm -f "$pid_file"
printf 'Stopped %s\n' "$label"
}
stop_all() {
stop_pid "fgAI dashboard" "$DASHBOARD_PID_FILE"
stop_pid "fgAI monitor" "$MONITOR_PID_FILE"
stop_pid "fgAI syslog listener" "$LISTENER_PID_FILE"
}
status_one() {
label="$1"
pid_file="$2"
if is_pid_running "$pid_file"; then
printf '%s running, pid %s\n' "$label" "$(cat "$pid_file")"
return 0
fi
printf '%s is not running\n' "$label"
return 1
}
status_all() {
status_one "fgAI syslog listener" "$LISTENER_PID_FILE" || true
if is_pid_running "$LISTENER_PID_FILE"; then
printf 'Input: udp://%s:%s\n' "$HOST" "$PORT" printf 'Input: udp://%s:%s\n' "$HOST" "$PORT"
printf 'Syslog file: %s\n' "$LOG_FILE" printf 'Syslog file: %s\n' "$LOG_FILE"
if command -v ss >/dev/null 2>&1; then if command -v ss >/dev/null 2>&1; then
ss -lunp 2>/dev/null | awk -v port=":$PORT" '$0 ~ port {print}' ss -lunp 2>/dev/null | awk -v port=":$PORT" '$0 ~ port {print}'
fi fi
return 0
fi fi
status_one "fgAI monitor" "$MONITOR_PID_FILE" || true
printf 'fgAI syslog listener is not running\n' status_one "fgAI dashboard" "$DASHBOARD_PID_FILE" || true
return 1 printf 'Dashboard: http://%s:%s\n' "$DASHBOARD_HOST" "$DASHBOARD_PORT"
printf 'Status file: %s\n' "$STATE_FILE"
} }
command="${1:-start}" command="${1:-start}"
case "$command" in case "$command" in
start) start)
start_listener start_all
;; ;;
stop) stop)
stop_listener stop_all
;; ;;
restart) restart)
stop_listener stop_all
start_listener start_all
;; ;;
status) status)
status_listener status_all
;; ;;
tail) tail)
mkdir -p "$(dirname "$LOG_FILE")" mkdir -p "$(dirname "$LOG_FILE")"

42
tests/test_anomaly.py Normal file
View File

@@ -0,0 +1,42 @@
import unittest
from fgai.anomaly import anomaly_summary, detect_source_anomalies
from fgai.logs import parse_log_line
class AnomalyTests(unittest.TestCase):
def test_scores_repeated_utm_public_source_as_anomaly(self):
events = [
parse_log_line(f'type=traffic srcip=10.0.0.{i} dstip=1.1.1.1 service=https action=accept sentbyte=100 rcvdbyte=100')
for i in range(1, 8)
]
events.extend(
parse_log_line(
'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 service=https '
'action=blocked severity=critical sentbyte=0 rcvdbyte=0'
)
for _ in range(5)
)
findings = detect_source_anomalies(events)
self.assertEqual(findings[0].subject, "8.8.8.8")
self.assertGreaterEqual(findings[0].score, 60)
self.assertIn(findings[0].severity, {"high", "critical"})
def test_summary_counts_severities(self):
events = [
parse_log_line(
'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 service=https '
'action=blocked severity=critical'
)
for _ in range(5)
]
summary = anomaly_summary(detect_source_anomalies(events))
self.assertEqual(summary["total"], 1)
if __name__ == "__main__":
unittest.main()

38
tests/test_monitor.py Normal file
View File

@@ -0,0 +1,38 @@
import tempfile
import unittest
from pathlib import Path
from fgai.monitor import build_status, write_status
class MonitorTests(unittest.TestCase):
def test_build_status_from_log_file(self):
with tempfile.TemporaryDirectory() as tmp:
log_path = Path(tmp) / "fg.log"
log_path.write_text(
"\n".join(
[
'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 service=https action=blocked severity=critical',
'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 service=https action=blocked severity=critical',
'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 service=https action=blocked severity=critical',
]
),
encoding="utf-8",
)
status = build_status(str(log_path))
self.assertEqual(status["summary"]["total"], 3)
self.assertGreaterEqual(len(status["anomalies"]), 1)
def test_write_status_creates_parent_directory(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "state" / "status.json"
write_status({"ok": True}, str(output))
self.assertTrue(output.exists())
if __name__ == "__main__":
unittest.main()