diff --git a/llm_team_ui.py b/llm_team_ui.py index 03e2a3f..ebeba8c 100644 --- a/llm_team_ui.py +++ b/llm_team_ui.py @@ -723,7 +723,7 @@ async function loadLogs() { async function loadThreats() { var view = document.getElementById('log-view'); try { - var r = await fetch('/api/admin/security'); + var r = await fetch('/api/admin/security?sort=' + currentSort); var d = await r.json(); var ips = d.ips || []; @@ -794,45 +794,130 @@ async function loadThreats() { view.appendChild(e); return; } + // Sort controls + mass action bar + var toolbar = document.createElement('div'); + toolbar.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap'; + var sortLabel = document.createElement('span'); + sortLabel.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#7a7872'; + sortLabel.textContent = 'Sort:'; + toolbar.appendChild(sortLabel); + ['hits','threat','recent','banned'].forEach(function(s){ + var btn = document.createElement('button'); + btn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;text-transform:uppercase;letter-spacing:0.5px;padding:4px 10px;border:2px solid '+(currentSort===s?'#e2b55a':'#2a2d35')+';border-radius:2px;background:transparent;color:'+(currentSort===s?'#e2b55a':'#7a7872')+';cursor:pointer'; + btn.textContent = s; + btn.onclick = function(){ currentSort=s; loadThreats(); }; + toolbar.appendChild(btn); + }); + // Mass action buttons + var spacer = document.createElement('div'); spacer.style.flex = '1'; toolbar.appendChild(spacer); + var selCount = document.createElement('span'); selCount.id = 'sel-count'; + selCount.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#7a7872'; + toolbar.appendChild(selCount); + var massBan = document.createElement('button'); massBan.className = 'ban-btn ban'; + massBan.textContent = 'Ban Selected'; massBan.onclick = function(){ massAction('ban'); }; + toolbar.appendChild(massBan); + var massUnban = document.createElement('button'); massUnban.className = 'ban-btn unban'; + massUnban.textContent = 'Unban Selected'; massUnban.onclick = function(){ massAction('unban'); }; + toolbar.appendChild(massUnban); + view.appendChild(toolbar); + 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, '-'); + // Checkbox for mass selection + var cb = document.createElement('input'); cb.type = 'checkbox'; + cb.className = 'ip-check'; cb.dataset.ip = ip.ip; + cb.style.cssText = 'width:16px;height:16px;cursor:pointer;flex-shrink:0;accent-color:#e2b55a;margin-top:2px'; + cb.onchange = updateSelCount; + card.appendChild(cb); + 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.ua_count > 1) addTag(ip.ua_count + ' UAs', 'tag-mode'); if (ip.banned) addTag('BANNED', 'tag-ok'); + if (ip.ban_jails && ip.ban_jails.length) addTag(ip.ban_jails.join(', '), 'tag-time'); info.appendChild(row); + // Fingerprint line + var fp = document.createElement('div'); fp.className = 'threat-paths'; + var fpParts = []; + if (ip.first_seen) fpParts.push('First: ' + ip.first_seen); + fpParts.push('Last: ' + ip.last_seen); + if (ip.methods) { var mm = Object.entries(ip.methods).map(function(e){return e[0]+':'+e[1]}).join(' '); if(mm) fpParts.push('Methods: '+mm); } + fp.textContent = fpParts.join(' | '); + info.appendChild(fp); + 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); + // AI verdicts if any + if (ip.ai_verdicts && ip.ai_verdicts.length) { + var aiDiv = document.createElement('div'); aiDiv.style.cssText = 'margin-top:4px'; + ip.ai_verdicts.forEach(function(v){ + var vl = document.createElement('div'); + vl.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#c084fc;padding:1px 0'; + vl.textContent = 'AI: ' + (v.action||'?').toUpperCase() + ' — ' + (v.reason||'') + ' [' + (v.attack_type||'?') + ']'; + aiDiv.appendChild(vl); + }); + info.appendChild(aiDiv); + } + + // Expandable raw logs (click to toggle) + var expandBtn = document.createElement('div'); + expandBtn.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:#e2b55a;cursor:pointer;margin-top:6px;opacity:0.5'; + expandBtn.textContent = '▶ Show ' + (ip.log_lines?ip.log_lines.length:0) + ' log entries'; + var logPanel = document.createElement('div'); + logPanel.style.cssText = 'display:none;margin-top:6px;background:rgba(0,0,0,0.3);border:1px solid #2a2d35;border-radius:2px;padding:8px;max-height:250px;overflow-y:auto;font-family:JetBrains Mono,monospace;font-size:9px;line-height:1.6;color:#7a7872;white-space:pre-wrap;word-break:break-all'; + if (ip.log_lines) logPanel.textContent = ip.log_lines.join('\n'); + // UAs section + if (ip.uas && ip.uas.length) { + var uaHeader = document.createElement('div'); + uaHeader.style.cssText = 'margin-top:8px;padding-top:6px;border-top:1px solid #2a2d35;color:#c084fc;font-size:8px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px'; + uaHeader.textContent = 'User Agents (' + ip.uas.length + ')'; + logPanel.appendChild(uaHeader); + ip.uas.forEach(function(ua){ + var uaLine = document.createElement('div'); uaLine.style.color = '#7a7872'; + uaLine.textContent = ua; logPanel.appendChild(uaLine); + }); + } + expandBtn.onclick = function(){ + if (logPanel.style.display === 'none') { + logPanel.style.display = 'block'; expandBtn.textContent = '▼ Hide log entries'; expandBtn.style.opacity = '1'; + } else { + logPanel.style.display = 'none'; expandBtn.textContent = '▶ Show ' + (ip.log_lines?ip.log_lines.length:0) + ' log entries'; expandBtn.style.opacity = '0.5'; + } + }; + info.appendChild(expandBtn); + info.appendChild(logPanel); card.appendChild(info); var actions = document.createElement('div'); actions.className = 'threat-actions'; + actions.style.cssText = 'display:flex;flex-direction:column;gap:4px;flex-shrink:0'; + var enrichBtn = document.createElement('button'); enrichBtn.className = 'ban-btn'; + enrichBtn.style.cssText += 'color:#d946ef;border-color:rgba(217,70,239,0.4)'; + enrichBtn.textContent = 'Enrich'; + enrichBtn.onclick = function(e) { e.stopPropagation(); enrichIP(ip.ip, card); }; + actions.appendChild(enrichBtn); if (ip.banned) { var ubtn = document.createElement('button'); ubtn.className = 'ban-btn unban'; ubtn.textContent = 'Unban'; - ubtn.onclick = function() { banAction(ip.ip, 'unban'); }; + ubtn.onclick = function(e) { e.stopPropagation(); 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'); }; + var bbtn = document.createElement('button'); bbtn.className = 'ban-btn ban'; bbtn.textContent = 'Ban'; + bbtn.onclick = function(e) { e.stopPropagation(); banAction(ip.ip, 'ban'); }; actions.appendChild(bbtn); } card.appendChild(actions); @@ -845,6 +930,148 @@ async function loadThreats() { } } +var currentSort = 'hits'; + +function updateSelCount() { + var checks = document.querySelectorAll('.ip-check:checked'); + var el = document.getElementById('sel-count'); + if (el) el.textContent = checks.length ? checks.length + ' selected' : ''; +} + +async function massAction(action) { + var checks = document.querySelectorAll('.ip-check:checked'); + if (!checks.length) return; + var ipList = []; + checks.forEach(function(c) { ipList.push(c.dataset.ip); }); + if (!confirm((action === 'ban' ? 'Ban' : 'Unban') + ' ' + ipList.length + ' IPs?\n\n' + ipList.join('\n'))) return; + try { + var r = await fetch('/api/admin/security/mass-ban', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ips: ipList, action: action}) + }); + var d = await r.json(); + if (d.ok) { setTimeout(function(){ loadThreats(); }, 300); } + } catch(e) { alert('Error: ' + e.message); } +} + +async function enrichIP(ip, card) { + // Find or create enrichment panel in the card + var existing = card.querySelector('.enrich-panel'); + if (existing) { existing.remove(); return; } + var panel = document.createElement('div'); + panel.className = 'enrich-panel'; + panel.style.cssText = 'background:rgba(217,70,239,0.04);border:2px solid rgba(217,70,239,0.2);border-radius:2px;padding:12px;margin-top:8px;grid-column:1/-1'; + panel.textContent = ''; + var loading = document.createElement('div'); + loading.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#d946ef'; + loading.textContent = 'Enriching ' + ip + '... (geo + AI analysis)'; + panel.appendChild(loading); + card.appendChild(panel); + + try { + var r = await fetch('/api/admin/security/enrich', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ip: ip}) + }); + var d = await r.json(); + panel.textContent = ''; + + // Geo section + if (d.geo && !d.geo.error) { + var g = d.geo; + var geoDiv = document.createElement('div'); + geoDiv.style.cssText = 'margin-bottom:10px'; + var gTitle = document.createElement('div'); + gTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:#d946ef;margin-bottom:6px;font-weight:700'; + gTitle.textContent = 'Geolocation + Network'; + geoDiv.appendChild(gTitle); + var gGrid = document.createElement('div'); + gGrid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px'; + var fields = [ + ['Location', (g.city||'?')+', '+(g.regionName||'?')+', '+(g.country||'?')], + ['ISP', g.isp||'?'], + ['Org', g.org||'?'], + ['AS', g.as||'?'], + ['Proxy', g.proxy ? 'YES' : 'No'], + ['Hosting', g.hosting ? 'YES' : 'No'], + ['Mobile', g.mobile ? 'YES' : 'No'], + ['Timezone', g.timezone||'?'] + ]; + fields.forEach(function(f){ + var box = document.createElement('div'); + box.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px'; + var label = document.createElement('span'); label.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px'; + label.textContent = f[0] + ': '; + var val = document.createElement('span'); + val.style.color = (f[0]==='Proxy'&&g.proxy)||(f[0]==='Hosting'&&g.hosting) ? '#e05252' : '#e8e6e3'; + val.textContent = f[1]; + box.appendChild(label); box.appendChild(val); + gGrid.appendChild(box); + }); + geoDiv.appendChild(gGrid); + panel.appendChild(geoDiv); + } + + // AI Analysis section + if (d.ai_analysis && !d.ai_analysis.error) { + var ai = d.ai_analysis; + var aiDiv = document.createElement('div'); + var aTitle = document.createElement('div'); + aTitle.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:2px;color:#d946ef;margin-bottom:6px;font-weight:700'; + aTitle.textContent = 'AI Threat Analysis (' + d.log_count + ' log entries)'; + aiDiv.appendChild(aTitle); + + var threatColor = {'critical':'#e05252','high':'#f59e0b','medium':'#e2b55a','low':'#7a7872','none':'#4ade80'}; + var summaryDiv = document.createElement('div'); + summaryDiv.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin-bottom:8px'; + [ + ['Threat', ai.threat_level||'?', threatColor[ai.threat_level]||'#7a7872'], + ['Type', ai.classification||'?', '#c084fc'], + ['Confidence', ((ai.confidence||0)*100).toFixed(0)+'%', '#e2b55a'], + ['Automated', ai.likely_automated?'YES':'No', ai.likely_automated?'#e05252':'#4ade80'] + ].forEach(function(f){ + var box = document.createElement('div'); box.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px'; + var label = document.createElement('span'); label.style.cssText = 'color:#7a7872;font-size:8px;text-transform:uppercase;letter-spacing:1px'; + label.textContent = f[0] + ': '; + var val = document.createElement('span'); val.style.cssText = 'font-weight:700;color:'+f[2]; + val.textContent = f[1]; + box.appendChild(label); box.appendChild(val); summaryDiv.appendChild(box); + }); + aiDiv.appendChild(summaryDiv); + + if (ai.summary) { + var summ = document.createElement('div'); + summ.style.cssText = 'font-size:11px;color:#e8e6e3;margin-bottom:6px;line-height:1.5'; + summ.textContent = ai.summary; aiDiv.appendChild(summ); + } + if (ai.pattern) { + var pat = document.createElement('div'); + pat.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#c084fc;margin-bottom:6px'; + pat.textContent = 'Pattern: ' + ai.pattern; aiDiv.appendChild(pat); + } + if (ai.indicators && ai.indicators.length) { + var indDiv = document.createElement('div'); + indDiv.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#7a7872;margin-bottom:6px'; + indDiv.textContent = 'Indicators: ' + ai.indicators.join(' | '); aiDiv.appendChild(indDiv); + } + if (ai.recommendation) { + var rec = document.createElement('div'); + rec.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#4ade80;border-left:2px solid #4ade80;padding-left:8px;margin-top:4px'; + rec.textContent = 'Recommendation: ' + ai.recommendation; aiDiv.appendChild(rec); + } + panel.appendChild(aiDiv); + } else if (d.ai_analysis && d.ai_analysis.error) { + var errDiv = document.createElement('div'); + errDiv.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px;color:#e05252'; + errDiv.textContent = 'AI error: ' + d.ai_analysis.error; + panel.appendChild(errDiv); + } + } catch(e) { + panel.textContent = 'Error: ' + e.message; + panel.style.color = '#e05252'; + } +} + async function banAction(ip, action) { try { var r = await fetch('/api/admin/security/ban', { @@ -3569,43 +3796,83 @@ def admin_ollama_models(): @app.route("/api/admin/security") @admin_required def admin_security_data(): - """Aggregate security log into IP-level threat intelligence.""" + """Aggregate security log into IP-level threat intelligence with full fingerprints.""" 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()}) + ips = collections.defaultdict(lambda: { + "hits": 0, "exploit_scans": 0, "login_fails": 0, "rate_limits": 0, + "first_seen": "", "last_seen": "", "paths": set(), "threat": "low", + "uas": set(), "methods": collections.Counter(), "log_lines": [], + "event_types": collections.Counter(), "ai_verdicts": [] + }) try: with open("/var/log/llm-team-security.log") as f: for line in f: - parts = line.strip().split(" ", 2) + line = line.strip() + if not line: + continue + parts = line.split(" ", 2) if len(parts) < 3: continue - ts = parts[0] + " " + parts[1] + ts = parts[0] + " " + parts[1].split(",")[0] 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 + # Check AI_BAN lines + if "AI_BAN" in rest or "AI_VERDICT" in rest: + 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 + if not entry["first_seen"]: + entry["first_seen"] = ts entry["last_seen"] = ts + # Categorize event if "EXPLOIT_SCAN" in rest: entry["exploit_scans"] += 1 - if "LOGIN_FAILED" in rest: + entry["event_types"]["exploit_scan"] += 1 + elif "LOGIN_FAILED" in rest: entry["login_fails"] += 1 - if "RATE_LIMITED" in rest: + entry["event_types"]["login_fail"] += 1 + elif "RATE_LIMITED" in rest: entry["rate_limits"] += 1 + entry["event_types"]["rate_limit"] += 1 + elif "AI_BAN" in rest: + entry["event_types"]["ai_ban"] += 1 + elif "MANUAL_BAN" in rest: + entry["event_types"]["manual_ban"] += 1 + elif "404_HIT" in rest: + entry["event_types"]["404"] += 1 + # Extract fields 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 "") + elif token.startswith("method="): + entry["methods"][token[7:]] += 1 + if "ua=" in rest: + ua = rest.split("ua=", 1)[1][:80] + entry["uas"].add(ua) + # Keep last 15 raw log lines per IP + entry["log_lines"].append(line) + if len(entry["log_lines"]) > 15: + entry["log_lines"].pop(0) except Exception: pass - # Calculate threat level + # Attach AI sentinel verdicts + for v in _sentinel_results: + ip = v.get("ip", "") + if ip in ips: + ips[ip]["ai_verdicts"].append(v) + + # Calculate threat level + fingerprint for ip, d in ips.items(): if d["exploit_scans"] >= 3: d["threat"] = "critical" @@ -3615,34 +3882,59 @@ def admin_security_data(): d["threat"] = "high" elif d["hits"] >= 10: d["threat"] = "medium" - d["paths"] = list(d["paths"])[:10] - d["uas"] = list(d["uas"])[:3] + # Fingerprint: multiple UAs = rotating scanner + if len(d["uas"]) >= 3: + d["threat"] = max(d["threat"], "high", key=["low","medium","high","critical"].index) + d["paths"] = sorted(d["paths"])[:15] + d["uas"] = sorted(d["uas"])[:5] + d["methods"] = dict(d["methods"]) + d["event_types"] = dict(d["event_types"]) # Get fail2ban status banned = set() + ban_jails = {} 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()) + ip = ip.strip() + if ip: + banned.add(ip) + ban_jails.setdefault(ip, []).append(jail) except Exception: pass # Build sorted result + sort_by = request.args.get("sort", "hits") result = [] - for ip, d in sorted(ips.items(), key=lambda x: x[1]["hits"], reverse=True): + for ip, d in ips.items(): if ip.startswith("192.168."): - continue # skip LAN + continue 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 + "first_seen": d["first_seen"], "last_seen": d["last_seen"], + "paths": d["paths"], "uas": d["uas"], "methods": d["methods"], + "event_types": d["event_types"], "threat": d["threat"], + "banned": ip in banned, "ban_jails": ban_jails.get(ip, []), + "ua_count": len(d["uas"]), + "log_lines": d["log_lines"], + "ai_verdicts": d["ai_verdicts"] }) + # Sort + threat_order = {"critical": 4, "high": 3, "medium": 2, "low": 1} + if sort_by == "threat": + result.sort(key=lambda x: (threat_order.get(x["threat"], 0), x["hits"]), reverse=True) + elif sort_by == "recent": + result.sort(key=lambda x: x["last_seen"], reverse=True) + elif sort_by == "banned": + result.sort(key=lambda x: (x["banned"], x["hits"]), reverse=True) + else: + result.sort(key=lambda x: x["hits"], reverse=True) + return jsonify({"ips": result[:100], "total_banned": len(banned), "banned_list": sorted(banned)}) @@ -3675,6 +3967,127 @@ def admin_ban_ip(): return jsonify({"error": "Invalid action"}), 400 +@app.route("/api/admin/security/enrich", methods=["POST"]) +@admin_required +def admin_enrich_ip(): + """Enrich an IP with geolocation, ISP, proxy detection, and AI analysis.""" + data = request.json or {} + ip = data.get("ip", "").strip() + if not ip: + return jsonify({"error": "IP required"}), 400 + + result = {"ip": ip, "geo": None, "ai_analysis": None, "error": None} + + # Step 1: Geolocation + ISP via ip-api.com + try: + geo_resp = requests.get( + f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city,isp,org,as,mobile,proxy,hosting,lat,lon,timezone", + timeout=5 + ) + geo = geo_resp.json() + if geo.get("status") == "success": + result["geo"] = geo + else: + result["geo"] = {"error": "lookup failed"} + except Exception as e: + result["geo"] = {"error": str(e)} + + # Step 2: Gather all log data for this IP + ip_logs = [] + try: + with open("/var/log/llm-team-security.log") as f: + for line in f: + if f"ip={ip}" in line: + ip_logs.append(line.strip()) + except Exception: + pass + + # Step 3: AI threat analysis with full context + try: + geo_ctx = "" + if result["geo"] and not result["geo"].get("error"): + g = result["geo"] + geo_ctx = f"Geolocation: {g.get('city','?')}, {g.get('regionName','?')}, {g.get('country','?')}\n" + geo_ctx += f"ISP: {g.get('isp','?')} | Org: {g.get('org','?')} | AS: {g.get('as','?')}\n" + geo_ctx += f"Proxy: {g.get('proxy',False)} | Hosting: {g.get('hosting',False)} | Mobile: {g.get('mobile',False)}\n" + + log_ctx = "\n".join(ip_logs[-20:]) if ip_logs else "No log entries found." + + prompt = ( + f"You are a cybersecurity analyst. Provide a detailed threat assessment for IP {ip}.\n\n" + f"{geo_ctx}\n" + f"Activity log ({len(ip_logs)} total entries, showing last 20):\n{log_ctx}\n\n" + "Provide your analysis as JSON:\n" + '{"threat_level": "none|low|medium|high|critical",\n' + ' "classification": "scanner|bruteforce|bot|researcher|targeted_attack|legitimate",\n' + ' "confidence": 0.0-1.0,\n' + ' "summary": "2-3 sentence threat assessment",\n' + ' "indicators": ["list of specific indicators found"],\n' + ' "recommendation": "specific recommended action",\n' + ' "likely_automated": true/false,\n' + ' "pattern": "description of attack pattern if any"}\n' + ) + + cfg = load_config() + base = cfg["providers"]["ollama"].get("base_url", "http://localhost:11434") + ai_resp = requests.post(f"{base}/api/generate", json={ + "model": SENTINEL_MODEL, "prompt": prompt, "stream": False, + "options": {"num_ctx": 4096, "temperature": 0.1} + }, timeout=60) + ai_resp.raise_for_status() + ai_text = ai_resp.json()["response"] + + # Parse JSON from AI response + text = ai_text.strip() + if "```" in text: + text = text.split("```")[1] + if text.startswith("json"): + text = text[4:] + start_idx = text.find("{") + end_idx = text.rfind("}") + 1 + if start_idx >= 0 and end_idx > start_idx: + result["ai_analysis"] = json.loads(text[start_idx:end_idx]) + else: + result["ai_analysis"] = {"raw": ai_text[:500]} + except Exception as e: + result["ai_analysis"] = {"error": str(e)} + + result["log_count"] = len(ip_logs) + return jsonify(result) + + +@app.route("/api/admin/security/mass-ban", methods=["POST"]) +@admin_required +def admin_mass_ban(): + """Ban or unban multiple IPs at once.""" + import subprocess + data = request.json or {} + ip_list = data.get("ips", []) + action = data.get("action", "ban") + if not ip_list: + return jsonify({"error": "No IPs provided"}), 400 + results = {"success": 0, "failed": 0, "skipped": 0} + for ip in ip_list: + ip = ip.strip() + if not ip or ip.startswith("192.168."): + results["skipped"] += 1 + continue + try: + if action == "ban": + subprocess.run(["fail2ban-client", "set", "llm-team-exploit", "banip", ip], + capture_output=True, text=True, timeout=5) + sec_log.warning("MASS_BAN ip=%s by=%s", ip, session.get("username", "admin")) + 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("MASS_UNBAN ip=%s by=%s", ip, session.get("username", "admin")) + results["success"] += 1 + except Exception: + results["failed"] += 1 + return jsonify({"ok": True, "results": results}) + + # ─── ADMIN MONITOR ───────────────────────────────────────────── @app.route("/admin/monitor")