fix ui
This commit is contained in:
@@ -75,7 +75,7 @@ def _rate_per_minute(events: list[LogEvent]) -> tuple[float | None, float]:
|
||||
|
||||
|
||||
def detect_source_anomalies(
|
||||
events: list[LogEvent], *, limit: int = 20, baselines: dict[str, dict[str, float | int]] | None = None
|
||||
events: list[LogEvent], *, limit: int = 20, baselines: dict[str, dict[str, object]] | None = None
|
||||
) -> list[AnomalyFinding]:
|
||||
baselines = baselines or {}
|
||||
by_src: dict[str, list[LogEvent]] = defaultdict(list)
|
||||
@@ -200,6 +200,19 @@ def detect_source_anomalies(
|
||||
score += min(15, distinct_dst_ports)
|
||||
reasons.append(f"many destination ports contacted ({distinct_dst_ports})")
|
||||
|
||||
baseline = baselines.get(src_ip)
|
||||
if baseline:
|
||||
known_destinations = set(baseline.get("known_destinations", []))
|
||||
known_ports = set(baseline.get("known_destination_ports", []))
|
||||
new_destinations = {event.dst_ip for event in src_events if event.dst_ip and event.dst_ip not in known_destinations}
|
||||
new_ports = {event.fields.get("dstport") for event in src_events if event.fields.get("dstport") and event.fields.get("dstport") not in known_ports}
|
||||
if len(known_destinations) >= 5 and len(new_destinations) >= 3:
|
||||
score += min(15, 5 + len(new_destinations))
|
||||
reasons.append(f"new destinations relative to historical baseline ({len(new_destinations)})")
|
||||
if len(known_ports) >= 3 and len(new_ports) >= 2:
|
||||
score += min(12, 4 + len(new_ports))
|
||||
reasons.append(f"new destination ports relative to historical baseline ({len(new_ports)})")
|
||||
|
||||
if _is_public_ip(src_ip) and (utm_count or deny_count >= 10):
|
||||
score += 10
|
||||
reasons.append("public source with repeated security-relevant events")
|
||||
@@ -232,6 +245,7 @@ def detect_source_anomalies(
|
||||
"distinct_dst_ports": distinct_dst_ports,
|
||||
"policy_count": len(policies),
|
||||
"implicit_deny_events": implicit_deny_count,
|
||||
"baseline_ready": int(src_ip in baselines),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -35,6 +35,10 @@ class BaselineStore:
|
||||
denies integer not null, utm integer not null,
|
||||
primary key (source_ip, bucket_start)
|
||||
);
|
||||
create table if not exists source_values (
|
||||
source_ip text not null, kind text not null, value text not null,
|
||||
seen_count integer not null, primary key (source_ip, kind, value)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -59,6 +63,13 @@ class BaselineStore:
|
||||
values[2] += _number(event.fields.get("hitcount"))
|
||||
values[3] += int(event.action in THREAT_ACTIONS)
|
||||
values[4] += int(is_utm_event(event))
|
||||
for kind, value in (("destination", event.dst_ip), ("destination_port", event.fields.get("dstport"))):
|
||||
if value:
|
||||
connection.execute(
|
||||
"""insert into source_values values (?, ?, ?, 1)
|
||||
on conflict(source_ip, kind, value) do update set seen_count=seen_count+1""",
|
||||
(event.src_ip, kind, value),
|
||||
)
|
||||
inserted += 1
|
||||
for (source_ip, bucket), values in pending.items():
|
||||
connection.execute(
|
||||
@@ -70,8 +81,8 @@ class BaselineStore:
|
||||
)
|
||||
return inserted
|
||||
|
||||
def profiles(self, source_ips: set[str]) -> dict[str, dict[str, float | int]]:
|
||||
profiles: dict[str, dict[str, float | int]] = {}
|
||||
def profiles(self, source_ips: set[str]) -> dict[str, dict[str, object]]:
|
||||
profiles: dict[str, dict[str, object]] = {}
|
||||
with self._connect() as connection:
|
||||
for source_ip in source_ips:
|
||||
rows = connection.execute(
|
||||
@@ -83,9 +94,14 @@ class BaselineStore:
|
||||
continue
|
||||
rates = [row[0] * 60 / self.bucket_seconds for row in rows]
|
||||
hit_rates = [row[2] * 60 / self.bucket_seconds for row in rows]
|
||||
known = connection.execute(
|
||||
"select kind, value from source_values where source_ip=?", (source_ip,)
|
||||
).fetchall()
|
||||
profiles[source_ip] = {
|
||||
"samples": len(rows),
|
||||
"event_rate_mean": mean(rates), "event_rate_stddev": pstdev(rates) or 1.0,
|
||||
"hitcount_rate_mean": mean(hit_rates), "hitcount_rate_stddev": pstdev(hit_rates) or 1.0,
|
||||
"known_destinations": [value for kind, value in known if kind == "destination"],
|
||||
"known_destination_ports": [value for kind, value in known if kind == "destination_port"],
|
||||
}
|
||||
return profiles
|
||||
|
||||
@@ -14,14 +14,14 @@ HTML = """<!doctype html>
|
||||
<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; }
|
||||
header { background: #102032; color: white; padding: 14px 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; }
|
||||
main { padding: 14px; max-width: 1440px; margin: 0 auto; }
|
||||
.hero { display: grid; grid-template-columns: 180px 1fr; gap: 12px; align-items: stretch; }
|
||||
.hero img { width: 100%; height: 144px; 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; }
|
||||
.panel { background: white; border: 1px solid #d9e0e7; border-radius: 6px; padding: 14px; margin-bottom: 12px; }
|
||||
.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; }
|
||||
@@ -33,11 +33,21 @@ HTML = """<!doctype html>
|
||||
.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; } }
|
||||
.capabilities { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||
.capability { border: 1px solid #cbd5df; background: #f8fafc; padding: 5px 8px; font-size: 12px; }
|
||||
.capability.on { border-color: #4d8b62; color: #176638; background: #effaf2; }
|
||||
.capability.warn { border-color: #bd8d2f; color: #865b00; background: #fff9e9; }
|
||||
.tabs { display: flex; border-bottom: 1px solid #d9e0e7; margin: 14px 0 12px; gap: 4px; }
|
||||
.tab { border: 0; border-bottom: 3px solid transparent; background: transparent; padding: 10px 14px; color: #536170; cursor: pointer; }
|
||||
.tab.active { border-bottom-color: #176b87; color: #102032; font-weight: 700; }
|
||||
[data-view] { display: none; } [data-view].active { display: block; }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr); gap: 12px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
@media (max-width: 860px) { .hero, .split { grid-template-columns: 1fr; } .hero img { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>fgAI Monitor</h1><div id="stamp" class="muted"></div></header>
|
||||
<header><h1>fgAI Monitor</h1><div id="stamp" class="muted"></div><div id="capabilities" class="capabilities"></div></header>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<img src="/images/FGinspectionagent.png" alt="FortiGate AI/ML Analyzer">
|
||||
@@ -46,13 +56,10 @@ HTML = """<!doctype html>
|
||||
<section class="panel"><h2>Live Status</h2><div id="liveStatus" class="muted">Waiting for monitor data.</div></section>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel"><h2>AI Assessment</h2><div id="llmAssessment" class="muted">LLM assessment disabled.</div></section>
|
||||
<section class="panel"><h2>Anomalies</h2><div id="anomalies"></div></section>
|
||||
<section class="panel"><h2>Recommendations</h2><div id="recommendations"></div></section>
|
||||
<section class="panel"><h2>Block Candidates</h2><div id="blocks"></div></section>
|
||||
<section class="panel"><h2>Threat Intelligence</h2><div id="reputation"></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>
|
||||
<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></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="findings"><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>
|
||||
</main>
|
||||
<script>
|
||||
function esc(value) {
|
||||
@@ -65,13 +72,16 @@ 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>`;
|
||||
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>`; }
|
||||
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 || {};
|
||||
const baseline = data.baseline || {};
|
||||
const threat = (data.capabilities || {}).threat_intel || {};
|
||||
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),
|
||||
@@ -79,6 +89,12 @@ async function refresh() {
|
||||
metric('Threat actions', s.threat_actions || 0),
|
||||
metric('Anomalies high+', (a.high || 0) + (a.critical || 0))
|
||||
].join('');
|
||||
const llm = data.llm_assessment || {};
|
||||
document.getElementById('capabilities').innerHTML = [
|
||||
capability('Baseline', baseline.enabled ? 'on' : 'warn', baseline.enabled ? `${baseline.sources_ready || 0} sources ready` : 'disabled'),
|
||||
capability('Ollama', llm.enabled && llm.status !== 'error' ? 'on' : 'warn', llm.enabled ? (llm.status || 'starting') : 'disabled'),
|
||||
capability('Threat Intel', threat.enabled && threat.configured ? 'on' : 'warn', threat.enabled ? `${threat.provider || 'unknown'}${threat.configured ? '' : ', key missing'}` : 'disabled')
|
||||
].join('');
|
||||
document.getElementById('liveStatus').innerHTML = [
|
||||
`Log file: <code>${esc(data.log_path || '')}</code>`,
|
||||
`Policy file: <code>${esc(data.policy_path || 'none')}</code>`,
|
||||
@@ -86,7 +102,6 @@ async function refresh() {
|
||||
`High anomalies: ${esc((a.high || 0))}`,
|
||||
`Baseline sources ready: ${esc((data.baseline || {}).sources_ready || 0)}`
|
||||
].join('<br>');
|
||||
const llm = data.llm_assessment || {};
|
||||
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 || [], [
|
||||
@@ -133,11 +148,17 @@ async function refresh() {
|
||||
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 Destinations</h3>' + table(d.top_destination_ips || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
|
||||
'<h3>Top Policy IDs</h3>' + table(d.top_policy_ids || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
|
||||
'<h3>Top Destination Ports</h3>' + table(d.top_destination_ports || [], [{label:'Value', key:'value'}, {label:'Count', key:'count'}]) +
|
||||
'<h3>Top Source Ports</h3>' + table(d.top_source_ports || [], [{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'}]);
|
||||
}
|
||||
document.querySelectorAll('.tab').forEach(button => button.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(item => item.classList.toggle('active', item === button));
|
||||
document.querySelectorAll('[data-view]').forEach(view => view.classList.toggle('active', view.dataset.view === button.dataset.tab));
|
||||
}));
|
||||
refresh();
|
||||
setInterval(refresh, 5000);
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@ from .logs import local_in_failures, read_events, summarize_events, top_field_va
|
||||
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
|
||||
from .threat_intel import ThreatIntelClient, enrich_ips, is_public_ip
|
||||
|
||||
|
||||
def build_status(
|
||||
@@ -37,6 +37,7 @@ def build_status(
|
||||
}
|
||||
)
|
||||
reputation = enrich_ips(intel_ips, limit=25)
|
||||
threat_intel_status = ThreatIntelClient().status()
|
||||
recommendations = build_recommendations(events, anomalies, reputation)
|
||||
block_candidates = suggest_block_candidates(
|
||||
events,
|
||||
@@ -60,8 +61,11 @@ def build_status(
|
||||
"summary": summarize_events(events),
|
||||
"anomaly_summary": anomaly_summary(anomalies),
|
||||
"baseline": {"enabled": bool(baseline), "sources_ready": len(profiles), "new_events_recorded": baseline_events},
|
||||
"capabilities": {"threat_intel": threat_intel_status},
|
||||
"diagnostics": {
|
||||
"top_source_ips": top_field_values(events, "srcip", limit=10),
|
||||
"top_destination_ips": top_field_values(events, "dstip", limit=10),
|
||||
"top_policy_ids": top_field_values(events, "policyid", limit=10),
|
||||
"top_destination_ports": top_field_values(events, "dstport", limit=10),
|
||||
"top_source_ports": top_field_values(events, "srcport", limit=10),
|
||||
"top_services": top_field_values(events, "service", limit=10),
|
||||
|
||||
@@ -29,6 +29,11 @@ class ThreatIntelClient:
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.cache = self._read_cache()
|
||||
|
||||
def status(self) -> dict[str, object]:
|
||||
provider = self._select_provider()
|
||||
has_key = bool(self.abuseipdb_key if provider == "abuseipdb" else self.virustotal_key)
|
||||
return {"enabled": self.enabled, "provider": provider, "configured": has_key}
|
||||
|
||||
def _read_cache(self) -> dict[str, dict[str, object]]:
|
||||
if not self.cache_path.exists():
|
||||
return {}
|
||||
|
||||
@@ -14,3 +14,4 @@ class BaselineTests(unittest.TestCase):
|
||||
store.ingest([parse_log_line(f"srcip=10.0.0.1 dstport={1000 + index} hitcount=2 sentbyte=5")], observed_at=1_700_000_000 + index * 300)
|
||||
profiles = store.profiles({"10.0.0.1"})
|
||||
self.assertEqual(profiles["10.0.0.1"]["samples"], 12)
|
||||
self.assertIn("1000", profiles["10.0.0.1"]["known_destination_ports"])
|
||||
|
||||
Reference in New Issue
Block a user