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}
|
.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}
|
.empty{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--text2);padding:40px;text-align:center;opacity:0.5}
|
||||||
.filter-input{flex:1}
|
.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>
|
</style>
|
||||||
</head><body>
|
</head><body>
|
||||||
<canvas id="bg-grid"></canvas>
|
<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" 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 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" 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>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<label>Lines:</label>
|
<label>Lines:</label>
|
||||||
@ -679,6 +702,10 @@ async function loadLogs() {
|
|||||||
view.textContent = '';
|
view.textContent = '';
|
||||||
var ld = document.createElement('div'); ld.className = 'empty'; ld.textContent = 'Loading...'; view.appendChild(ld);
|
var ld = document.createElement('div'); ld.className = 'empty'; ld.textContent = 'Loading...'; view.appendChild(ld);
|
||||||
try {
|
try {
|
||||||
|
if (currentSource === 'threats') {
|
||||||
|
await loadThreats();
|
||||||
|
return;
|
||||||
|
}
|
||||||
var r = await fetch('/api/admin/logs?source=' + currentSource + '&limit=' + limit);
|
var r = await fetch('/api/admin/logs?source=' + currentSource + '&limit=' + limit);
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
if (currentSource === 'runs') {
|
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();
|
loadLogs();
|
||||||
setInterval(function() { if (currentSource !== 'runs') loadLogs(); }, 10000);
|
setInterval(function() { if (currentSource !== 'runs' && currentSource !== 'threats') loadLogs(); }, 10000);
|
||||||
</script>
|
</script>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
|
|
||||||
@ -3397,6 +3517,117 @@ def admin_ollama_models():
|
|||||||
return jsonify({"models": [], "error": str(e)})
|
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 ─────────────────────────────────────────────
|
# ─── ADMIN MONITOR ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/admin/monitor")
|
@app.route("/admin/monitor")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user