diff --git a/llm_team_ui.py b/llm_team_ui.py index cc1b620..439f10f 100644 --- a/llm_team_ui.py +++ b/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} @@ -582,7 +604,8 @@ h1 span{color:var(--accent)}
Run History
Nginx Errors
Nginx Access
-
Security
+
Security Raw
+
Threat Intel
@@ -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); """ @@ -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")