IP threat intel: sorting, mass ban, enrichment with geo + AI analysis

Sorting:
- Sort by: hits, threat level, recent activity, banned status
- Active sort button highlighted in amber

Mass operations:
- Checkbox per IP for multi-select
- "Ban Selected" / "Unban Selected" buttons with confirmation
- /api/admin/security/mass-ban endpoint handles batch operations
- Selection counter shows "N selected"

IP Enrichment (click "Enrich" button per IP):
- Geolocation via ip-api.com (country, city, ISP, org, AS number)
- Proxy/hosting/mobile detection flags (red for proxy/hosting)
- AI threat analysis via local qwen2.5:
  - Threat level, classification, confidence score
  - Attack pattern description
  - Specific indicators list
  - Automated detection flag
  - Actionable recommendation
- Enrichment panel expands inline below the IP card (toggle)

Per-IP drill-down:
- Expandable raw log lines per IP (click to show/hide)
- User agent listing with count
- First seen / last seen timestamps
- HTTP method breakdown (GET:5 POST:2)
- AI sentinel verdicts shown inline
- Jail information for banned IPs

Enhanced backend:
- Security API returns per-IP log lines, first_seen, methods, event_types
- AI verdicts attached to IP records
- Multiple UA detection (fingerprint: rotating scanner)
- Sort parameter support (?sort=threat|hits|recent|banned)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-03-26 03:24:32 -05:00
parent de4ca533dd
commit 472a5d0917

View File

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