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:
parent
de4ca533dd
commit
472a5d0917
469
llm_team_ui.py
469
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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user