Interactive threat intelligence dashboard with one-click ban
Security API: - /api/admin/security — aggregates security log into per-IP threat intel (hit count, exploit scans, login fails, paths probed, threat level) - /api/admin/security/ban — manual ban/unban via fail2ban (logs MANUAL_BAN/MANUAL_UNBAN to security log) Threat Intel tab in /logs: - Summary stats: Critical IPs, High Threat, Currently Banned - Per-IP cards showing: threat level, hit count, scan count, paths probed - Critical IPs have red border, high threat amber - One-click "Ban 24h" button per IP (calls fail2ban-client banip) - One-click "Unban" for currently banned IPs - Banned IPs shown at reduced opacity - LAN IPs (192.168.*) filtered out fail2ban tuning: - llm-team-exploit findtime: 600s → 3600s (catch slow scanners) - llm-team-exploit maxretry: 3 → 2 (more aggressive) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
21c8c2a3e5
commit
f1bb2a92e7
235
llm_team_ui.py
235
llm_team_ui.py
@ -566,6 +566,28 @@ h1 span{color:var(--accent)}
|
||||
.run-error{font-size:10px;color:var(--red);border-left:2px solid var(--red);padding-left:8px;margin:4px 0}
|
||||
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:40px;text-align:center;opacity:0.5}
|
||||
.filter-input{flex:1}
|
||||
.threat-card{background:rgba(0,0,0,0.25);border:2px solid var(--border);border-radius:2px;padding:12px 14px;margin-bottom:6px;display:flex;align-items:flex-start;gap:12px}
|
||||
.threat-card.critical{border-color:var(--red);background:rgba(224,82,82,0.04)}
|
||||
.threat-card.high{border-color:#f59e0b;background:rgba(245,158,11,0.03)}
|
||||
.threat-card.banned{opacity:0.5}
|
||||
.threat-ip{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:700;min-width:130px;color:var(--text)}
|
||||
.threat-info{flex:1;min-width:0}
|
||||
.threat-row{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:4px}
|
||||
.threat-paths{font-family:'JetBrains Mono',monospace;font-size:9px;color:var(--text2);margin-top:4px}
|
||||
.threat-actions{display:flex;gap:4px;flex-shrink:0}
|
||||
.ban-btn{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:5px 12px;border:2px solid;border-radius:2px;cursor:pointer;font-weight:700;background:transparent}
|
||||
.ban-btn.ban{color:var(--red);border-color:rgba(224,82,82,0.4)}
|
||||
.ban-btn.ban:hover{background:rgba(224,82,82,0.1)}
|
||||
.ban-btn.unban{color:var(--green);border-color:rgba(74,222,128,0.4)}
|
||||
.ban-btn.unban:hover{background:rgba(74,222,128,0.1)}
|
||||
.threat-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:16px}
|
||||
.ts-box{background:rgba(0,0,0,0.3);border:2px solid var(--border);border-radius:2px;padding:12px;text-align:center;backdrop-filter:blur(16px)}
|
||||
.ts-val{font-family:'JetBrains Mono',monospace;font-size:20px;font-weight:700}
|
||||
.ts-val.red{color:var(--red)}
|
||||
.ts-val.green{color:var(--green)}
|
||||
.ts-val.amber{color:#f59e0b}
|
||||
.ts-label{font-family:'JetBrains Mono',monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:var(--text2);margin-top:4px}
|
||||
.tag{font-family:'JetBrains Mono',monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;padding:3px 8px;border:1px solid;border-radius:1px;font-weight:600}
|
||||
</style>
|
||||
</head><body>
|
||||
<canvas id="bg-grid"></canvas>
|
||||
@ -582,7 +604,8 @@ h1 span{color:var(--accent)}
|
||||
<div class="tab" data-src="runs" onclick="switchTab(this)">Run History</div>
|
||||
<div class="tab err" data-src="nginx_error" onclick="switchTab(this)">Nginx Errors</div>
|
||||
<div class="tab" data-src="nginx_access" onclick="switchTab(this)">Nginx Access</div>
|
||||
<div class="tab err" data-src="security" onclick="switchTab(this)">Security</div>
|
||||
<div class="tab err" data-src="security" onclick="switchTab(this)">Security Raw</div>
|
||||
<div class="tab err" data-src="threats" onclick="switchTab(this)">Threat Intel</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>Lines:</label>
|
||||
@ -679,6 +702,10 @@ async function loadLogs() {
|
||||
view.textContent = '';
|
||||
var ld = document.createElement('div'); ld.className = 'empty'; ld.textContent = 'Loading...'; view.appendChild(ld);
|
||||
try {
|
||||
if (currentSource === 'threats') {
|
||||
await loadThreats();
|
||||
return;
|
||||
}
|
||||
var r = await fetch('/api/admin/logs?source=' + currentSource + '&limit=' + limit);
|
||||
var d = await r.json();
|
||||
if (currentSource === 'runs') {
|
||||
@ -693,8 +720,101 @@ async function loadLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadThreats() {
|
||||
var view = document.getElementById('log-view');
|
||||
try {
|
||||
var r = await fetch('/api/admin/security');
|
||||
var d = await r.json();
|
||||
var ips = d.ips || [];
|
||||
view.textContent = '';
|
||||
|
||||
// Summary stats
|
||||
var summary = document.createElement('div');
|
||||
summary.className = 'threat-summary';
|
||||
var critical = ips.filter(function(i){return i.threat==='critical'}).length;
|
||||
var high = ips.filter(function(i){return i.threat==='high'}).length;
|
||||
var banned = d.total_banned || 0;
|
||||
[{v:critical,l:'Critical IPs',c:'red'},{v:high,l:'High Threat',c:'amber'},{v:banned,l:'Currently Banned',c:'green'}].forEach(function(s){
|
||||
var box = document.createElement('div'); box.className = 'ts-box';
|
||||
var val = document.createElement('div'); val.className = 'ts-val ' + s.c; val.textContent = s.v;
|
||||
var lab = document.createElement('div'); lab.className = 'ts-label'; lab.textContent = s.l;
|
||||
box.appendChild(val); box.appendChild(lab); summary.appendChild(box);
|
||||
});
|
||||
view.appendChild(summary);
|
||||
|
||||
if (!ips.length) {
|
||||
var e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No external IP activity recorded';
|
||||
view.appendChild(e); return;
|
||||
}
|
||||
|
||||
ips.forEach(function(ip) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'threat-card ' + ip.threat + (ip.banned ? ' banned' : '');
|
||||
card.id = 'ip-' + ip.ip.replace(/\./g, '-');
|
||||
|
||||
var ipEl = document.createElement('div'); ipEl.className = 'threat-ip'; ipEl.textContent = ip.ip;
|
||||
card.appendChild(ipEl);
|
||||
|
||||
var info = document.createElement('div'); info.className = 'threat-info';
|
||||
var row = document.createElement('div'); row.className = 'threat-row';
|
||||
|
||||
function addTag(text, cls) { var t = document.createElement('span'); t.className = 'tag ' + cls; t.textContent = text; row.appendChild(t); }
|
||||
|
||||
addTag(ip.threat, ip.threat === 'critical' ? 'tag-err' : ip.threat === 'high' ? 'tag-mode' : 'tag-time');
|
||||
addTag(ip.hits + ' hits', 'tag-time');
|
||||
if (ip.exploit_scans) addTag(ip.exploit_scans + ' scans', 'tag-err');
|
||||
if (ip.login_fails) addTag(ip.login_fails + ' login fails', 'tag-err');
|
||||
if (ip.banned) addTag('BANNED', 'tag-ok');
|
||||
info.appendChild(row);
|
||||
|
||||
if (ip.paths && ip.paths.length) {
|
||||
var paths = document.createElement('div'); paths.className = 'threat-paths';
|
||||
paths.textContent = 'Paths: ' + ip.paths.join(', ');
|
||||
info.appendChild(paths);
|
||||
}
|
||||
var lastSeen = document.createElement('div'); lastSeen.className = 'threat-paths';
|
||||
lastSeen.textContent = 'Last: ' + ip.last_seen;
|
||||
info.appendChild(lastSeen);
|
||||
|
||||
card.appendChild(info);
|
||||
|
||||
var actions = document.createElement('div'); actions.className = 'threat-actions';
|
||||
if (ip.banned) {
|
||||
var ubtn = document.createElement('button'); ubtn.className = 'ban-btn unban'; ubtn.textContent = 'Unban';
|
||||
ubtn.onclick = function() { banAction(ip.ip, 'unban'); };
|
||||
actions.appendChild(ubtn);
|
||||
} else {
|
||||
var bbtn = document.createElement('button'); bbtn.className = 'ban-btn ban'; bbtn.textContent = 'Ban 24h';
|
||||
bbtn.onclick = function() { banAction(ip.ip, 'ban'); };
|
||||
actions.appendChild(bbtn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
view.appendChild(card);
|
||||
});
|
||||
} catch(e) {
|
||||
view.textContent = '';
|
||||
var err = document.createElement('div'); err.className = 'empty'; err.textContent = 'Error: ' + e.message;
|
||||
view.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function banAction(ip, action) {
|
||||
try {
|
||||
var r = await fetch('/api/admin/security/ban', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ip: ip, action: action})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.ok) {
|
||||
var el = document.getElementById('ip-' + ip.replace(/\./g, '-'));
|
||||
if (el) { el.style.transition = 'opacity 0.3s'; el.style.opacity = '0.3'; }
|
||||
setTimeout(function() { loadThreats(); }, 500);
|
||||
} else { alert('Error: ' + (d.error || 'unknown')); }
|
||||
} catch(e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
loadLogs();
|
||||
setInterval(function() { if (currentSource !== 'runs') loadLogs(); }, 10000);
|
||||
setInterval(function() { if (currentSource !== 'runs' && currentSource !== 'threats') loadLogs(); }, 10000);
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
@ -3397,6 +3517,117 @@ def admin_ollama_models():
|
||||
return jsonify({"models": [], "error": str(e)})
|
||||
|
||||
|
||||
# ─── SECURITY DASHBOARD ───────────────────────────────────────
|
||||
|
||||
@app.route("/api/admin/security")
|
||||
@admin_required
|
||||
def admin_security_data():
|
||||
"""Aggregate security log into IP-level threat intelligence."""
|
||||
import subprocess, collections
|
||||
ips = collections.defaultdict(lambda: {"hits": 0, "exploit_scans": 0, "login_fails": 0, "rate_limits": 0, "last_seen": "", "paths": set(), "threat": "low", "uas": set()})
|
||||
try:
|
||||
with open("/var/log/llm-team-security.log") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(" ", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
ts = parts[0] + " " + parts[1]
|
||||
rest = parts[2]
|
||||
# Extract IP
|
||||
ip_match = None
|
||||
for token in rest.split():
|
||||
if token.startswith("ip="):
|
||||
ip_match = token[3:]
|
||||
break
|
||||
if not ip_match:
|
||||
continue
|
||||
entry = ips[ip_match]
|
||||
entry["hits"] += 1
|
||||
entry["last_seen"] = ts
|
||||
if "EXPLOIT_SCAN" in rest:
|
||||
entry["exploit_scans"] += 1
|
||||
if "LOGIN_FAILED" in rest:
|
||||
entry["login_fails"] += 1
|
||||
if "RATE_LIMITED" in rest:
|
||||
entry["rate_limits"] += 1
|
||||
for token in rest.split():
|
||||
if token.startswith("path="):
|
||||
entry["paths"].add(token[5:])
|
||||
if token.startswith("ua="):
|
||||
entry["uas"].add(rest.split("ua=", 1)[1][:60] if "ua=" in rest else "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Calculate threat level
|
||||
for ip, d in ips.items():
|
||||
if d["exploit_scans"] >= 3:
|
||||
d["threat"] = "critical"
|
||||
elif d["exploit_scans"] >= 1:
|
||||
d["threat"] = "high"
|
||||
elif d["login_fails"] >= 3:
|
||||
d["threat"] = "high"
|
||||
elif d["hits"] >= 10:
|
||||
d["threat"] = "medium"
|
||||
d["paths"] = list(d["paths"])[:10]
|
||||
d["uas"] = list(d["uas"])[:3]
|
||||
|
||||
# Get fail2ban status
|
||||
banned = set()
|
||||
for jail in ["llm-team-exploit", "llm-team-login", "nginx-botsearch", "nginx-bad-request", "nginx-forbidden"]:
|
||||
try:
|
||||
result = subprocess.run(["fail2ban-client", "status", jail], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split("\n"):
|
||||
if "Banned IP list" in line:
|
||||
for ip in line.split(":", 1)[1].strip().split():
|
||||
if ip.strip():
|
||||
banned.add(ip.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build sorted result
|
||||
result = []
|
||||
for ip, d in sorted(ips.items(), key=lambda x: x[1]["hits"], reverse=True):
|
||||
if ip.startswith("192.168."):
|
||||
continue # skip LAN
|
||||
result.append({
|
||||
"ip": ip, "hits": d["hits"], "exploit_scans": d["exploit_scans"],
|
||||
"login_fails": d["login_fails"], "rate_limits": d["rate_limits"],
|
||||
"last_seen": d["last_seen"], "paths": d["paths"], "uas": d["uas"],
|
||||
"threat": d["threat"], "banned": ip in banned
|
||||
})
|
||||
|
||||
return jsonify({"ips": result[:100], "total_banned": len(banned), "banned_list": sorted(banned)})
|
||||
|
||||
|
||||
@app.route("/api/admin/security/ban", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_ban_ip():
|
||||
"""Manually ban/unban an IP via fail2ban."""
|
||||
import subprocess
|
||||
data = request.json or {}
|
||||
ip = data.get("ip", "").strip()
|
||||
action = data.get("action", "ban")
|
||||
if not ip:
|
||||
return jsonify({"error": "IP required"}), 400
|
||||
if ip.startswith("192.168."):
|
||||
return jsonify({"error": "Cannot ban LAN addresses"}), 400
|
||||
try:
|
||||
if action == "ban":
|
||||
subprocess.run(["fail2ban-client", "set", "llm-team-exploit", "banip", ip],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
sec_log.warning("MANUAL_BAN ip=%s by=%s", ip, session.get("username", "admin"))
|
||||
return jsonify({"ok": True, "message": f"Banned {ip}"})
|
||||
elif action == "unban":
|
||||
for jail in ["llm-team-exploit", "llm-team-login", "nginx-botsearch", "nginx-bad-request", "nginx-forbidden"]:
|
||||
subprocess.run(["fail2ban-client", "set", jail, "unbanip", ip],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
sec_log.warning("MANUAL_UNBAN ip=%s by=%s", ip, session.get("username", "admin"))
|
||||
return jsonify({"ok": True, "message": f"Unbanned {ip}"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"error": "Invalid action"}), 400
|
||||
|
||||
|
||||
# ─── ADMIN MONITOR ─────────────────────────────────────────────
|
||||
|
||||
@app.route("/admin/monitor")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user