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:
root 2026-03-26 03:05:01 -05:00
parent 21c8c2a3e5
commit f1bb2a92e7

View File

@ -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")