diff --git a/llm_team_ui.py b/llm_team_ui.py index 3d4fcd8..252430b 100644 --- a/llm_team_ui.py +++ b/llm_team_ui.py @@ -606,6 +606,7 @@ h1 span{color:var(--accent)}
Nginx Access
Security Raw
Threat Intel
+
Wall of Shame
@@ -706,6 +707,10 @@ async function loadLogs() { await loadThreats(); return; } + if (currentSource === 'shame') { + await loadWallOfShame(); + return; + } var r = await fetch('/api/admin/logs?source=' + currentSource + '&limit=' + limit); var d = await r.json(); if (currentSource === 'runs') { @@ -1167,6 +1172,18 @@ async function enrichIP(ip, card) { errDiv.textContent = 'AI error: ' + d.ai_analysis.error; panel.appendChild(errDiv); } + // Saved indicator + if (d.saved) { + var savedDiv = document.createElement('div'); + savedDiv.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#4ade80;margin-top:8px;text-transform:uppercase;letter-spacing:1px'; + savedDiv.textContent = '✓ Saved to Wall of Shame database'; + panel.appendChild(savedDiv); + } else if (d.save_error) { + var seDiv = document.createElement('div'); + seDiv.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:9px;color:#e05252;margin-top:8px'; + seDiv.textContent = 'Save error: ' + d.save_error; + panel.appendChild(seDiv); + } } catch(e) { panel.textContent = 'Error: ' + e.message; panel.style.color = '#e05252'; @@ -1188,8 +1205,145 @@ async function banAction(ip, action) { } catch(e) { alert('Error: ' + e.message); } } +async function loadWallOfShame() { + var view = document.getElementById('log-view'); + view.textContent = ''; + try { + var r = await fetch('/api/admin/wall-of-shame?sort=enriched_at&order=desc'); + var d = await r.json(); + var entries = d.entries || []; + if (!entries.length) { + var e = document.createElement('div'); e.className = 'empty'; + e.textContent = 'No enriched IPs yet. Use the "Enrich" button on Threat Intel to scan IPs.'; + view.appendChild(e); return; + } + + // Stats bar + var stats = document.createElement('div'); + stats.style.cssText = 'display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:16px'; + var total = entries.length; + var crit = entries.filter(function(e){return e.threat_level==='critical'}).length; + var high = entries.filter(function(e){return e.threat_level==='high'}).length; + var proxies = entries.filter(function(e){return e.is_proxy}).length; + var automated = entries.filter(function(e){return e.likely_automated}).length; + [{v:total,l:'Total Profiled',c:'#d946ef'},{v:crit,l:'Critical',c:'#e05252'},{v:high,l:'High',c:'#f59e0b'},{v:proxies,l:'Proxies',c:'#e05252'},{v:automated,l:'Automated',c:'#c084fc'}].forEach(function(s){ + var box = document.createElement('div'); + box.style.cssText = 'background:rgba(0,0,0,0.3);border:2px solid #2a2d35;border-radius:2px;padding:12px;text-align:center;backdrop-filter:blur(16px)'; + var val = document.createElement('div'); + val.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:20px;font-weight:700;color:'+s.c; + val.textContent = s.v; + var lab = document.createElement('div'); + lab.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:8px;text-transform:uppercase;letter-spacing:1.5px;color:#7a7872;margin-top:4px'; + lab.textContent = s.l; + box.appendChild(val); box.appendChild(lab); stats.appendChild(box); + }); + view.appendChild(stats); + + // Table + var table = document.createElement('div'); + table.style.cssText = 'font-family:JetBrains Mono,monospace;font-size:10px'; + + // Header + var hdr = document.createElement('div'); + hdr.style.cssText = 'display:grid;grid-template-columns:130px 70px 100px 1fr 80px 60px;gap:8px;padding:8px 12px;border-bottom:2px solid #2a2d35;color:#7a7872;text-transform:uppercase;letter-spacing:1px;font-size:8px;font-weight:700'; + ['IP','Threat','Type','Summary','Country','Ports'].forEach(function(h){ + var cell = document.createElement('span'); cell.textContent = h; hdr.appendChild(cell); + }); + table.appendChild(hdr); + + entries.forEach(function(e) { + var row = document.createElement('div'); + row.style.cssText = 'display:grid;grid-template-columns:130px 70px 100px 1fr 80px 60px;gap:8px;padding:8px 12px;border-bottom:1px solid rgba(42,45,53,0.3);align-items:center;cursor:pointer;transition:background 0.1s'; + row.onmouseenter = function(){row.style.background='rgba(217,70,239,0.03)'}; + row.onmouseleave = function(){row.style.background='transparent'}; + + // IP + var ipCell = document.createElement('span'); ipCell.style.cssText = 'font-weight:700;color:#e8e6e3'; + ipCell.textContent = e.ip; row.appendChild(ipCell); + + // Threat + var threatColors = {critical:'#e05252',high:'#f59e0b',medium:'#e2b55a',low:'#7a7872'}; + var tCell = document.createElement('span'); tCell.style.cssText = 'font-weight:700;color:'+(threatColors[e.threat_level]||'#7a7872'); + tCell.textContent = (e.threat_level||'?').toUpperCase(); row.appendChild(tCell); + + // Type + var cCell = document.createElement('span'); cCell.style.color = '#c084fc'; + cCell.textContent = e.classification || e.attack_type || '?'; row.appendChild(cCell); + + // Summary + var sCell = document.createElement('span'); sCell.style.cssText = 'color:#7a7872;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; + sCell.textContent = e.summary || ''; sCell.title = e.summary || ''; row.appendChild(sCell); + + // Country + var coCell = document.createElement('span'); coCell.style.color = '#e8e6e3'; + coCell.textContent = e.country_code || '?'; row.appendChild(coCell); + + // Ports + var pCell = document.createElement('span'); pCell.style.color = '#e05252'; + var ports = e.open_ports || []; + pCell.textContent = ports.length ? ports.join(',') : '-'; row.appendChild(pCell); + + // Click to expand detail + var detail = document.createElement('div'); + detail.style.cssText = 'display:none;grid-column:1/-1;padding:10px 0;border-bottom:1px solid rgba(217,70,239,0.15)'; + row.onclick = function() { + if (detail.style.display === 'none') { + detail.style.display = 'block'; + detail.textContent = ''; + var grid = document.createElement('div'); + grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:6px;font-size:10px'; + var fields = [ + ['ISP', e.isp], ['Org', e.org], ['ASN', e.asn], + ['City', (e.city||'?')+', '+(e.country||'?')], + ['Proxy', e.is_proxy?'YES':'No'], ['Hosting', e.is_hosting?'YES':'No'], + ['Confidence', ((e.confidence||0)*100).toFixed(0)+'%'], + ['Automated', e.likely_automated?'YES':'No'], + ['Blocklists', (e.blocklist_count||0)+'/'+(e.blocklist_total||0)], + ['Log Entries', e.log_count||0], + ['Scanned', e.enriched_at ? new Date(e.enriched_at).toLocaleString() : '?'], + ['Updated', e.updated_at ? new Date(e.updated_at).toLocaleString() : '?'] + ]; + fields.forEach(function(f) { + var box = document.createElement('div'); + 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'&&e.is_proxy)||(f[0]==='Hosting'&&e.is_hosting)||(f[0]==='Automated'&&e.likely_automated) ? '#e05252' : '#e8e6e3'; + val.textContent = f[1]; + box.appendChild(label); box.appendChild(val); grid.appendChild(box); + }); + detail.appendChild(grid); + if (e.pattern) { + var pat = document.createElement('div'); + pat.style.cssText = 'margin-top:6px;color:#c084fc;font-size:10px'; + pat.textContent = 'Pattern: ' + e.pattern; detail.appendChild(pat); + } + if (e.recommendation) { + var rec = document.createElement('div'); + rec.style.cssText = 'margin-top:4px;color:#4ade80;border-left:2px solid #4ade80;padding-left:8px;font-size:10px'; + rec.textContent = 'Rec: ' + e.recommendation; detail.appendChild(rec); + } + if (e.indicators && e.indicators.length) { + var ind = document.createElement('div'); + ind.style.cssText = 'margin-top:4px;color:#7a7872;font-size:9px'; + ind.textContent = 'Indicators: ' + e.indicators.join(' | '); detail.appendChild(ind); + } + } else { + detail.style.display = 'none'; + } + }; + table.appendChild(row); + table.appendChild(detail); + }); + view.appendChild(table); + } catch(e) { + var err = document.createElement('div'); err.className = 'empty'; err.textContent = 'Error: ' + e.message; + view.appendChild(err); + } +} + loadLogs(); -setInterval(function() { if (currentSource !== 'runs' && currentSource !== 'threats') loadLogs(); }, 10000); +setInterval(function() { if (currentSource !== 'runs' && currentSource !== 'threats' && currentSource !== 'shame') loadLogs(); }, 10000); """ @@ -4192,9 +4346,108 @@ def admin_enrich_ip(): result["ai_analysis"] = {"error": str(e)} result["log_count"] = len(ip_logs) + + # Step 5: Save to Wall of Shame database + try: + geo = result.get("geo") or {} + ai = result.get("ai_analysis") or {} + wc = result.get("webcheck") or {} + open_ports = json.dumps(wc.get("ports", {}).get("openPorts", [])) + bl = wc.get("block_lists", {}).get("blocklists", []) + blocked = [b["server"] for b in bl if b.get("isBlocked")] + tr_hops = [] + if wc.get("trace_route") and wc["trace_route"].get("result"): + for h in wc["trace_route"]["result"]: + if isinstance(h, dict): + hop_ip = list(h.keys())[0] + tr_hops.append({"ip": hop_ip, "latency": h[hop_ip][0] if h[hop_ip] else None}) + with get_db() as conn: + with conn.cursor() as cur: + cur.execute(""" + INSERT INTO threat_intel (ip, threat_level, classification, confidence, summary, + indicators, recommendation, pattern, attack_type, likely_automated, + country, country_code, city, isp, org, asn, is_proxy, is_hosting, + open_ports, blocklist_count, blocklist_total, blocklists_blocked, + reverse_dns, traceroute, log_count, banned, raw_data, enriched_at, updated_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW()) + ON CONFLICT (ip) DO UPDATE SET + threat_level=EXCLUDED.threat_level, classification=EXCLUDED.classification, + confidence=EXCLUDED.confidence, summary=EXCLUDED.summary, + indicators=EXCLUDED.indicators, recommendation=EXCLUDED.recommendation, + pattern=EXCLUDED.pattern, attack_type=EXCLUDED.attack_type, + likely_automated=EXCLUDED.likely_automated, + country=EXCLUDED.country, country_code=EXCLUDED.country_code, city=EXCLUDED.city, + isp=EXCLUDED.isp, org=EXCLUDED.org, asn=EXCLUDED.asn, + is_proxy=EXCLUDED.is_proxy, is_hosting=EXCLUDED.is_hosting, + open_ports=EXCLUDED.open_ports, blocklist_count=EXCLUDED.blocklist_count, + blocklist_total=EXCLUDED.blocklist_total, blocklists_blocked=EXCLUDED.blocklists_blocked, + reverse_dns=EXCLUDED.reverse_dns, traceroute=EXCLUDED.traceroute, + log_count=EXCLUDED.log_count, banned=EXCLUDED.banned, + raw_data=EXCLUDED.raw_data, updated_at=NOW() + """, ( + ip, ai.get("threat_level", "unknown"), ai.get("classification"), + ai.get("confidence", 0), ai.get("summary"), + json.dumps(ai.get("indicators", [])), ai.get("recommendation"), + ai.get("pattern"), ai.get("attack_type"), ai.get("likely_automated", False), + geo.get("country"), geo.get("countryCode"), geo.get("city"), + geo.get("isp"), geo.get("org"), geo.get("as"), + geo.get("proxy", False), geo.get("hosting", False), + open_ports, len(blocked), len(bl), json.dumps(blocked), + "", json.dumps(tr_hops), len(ip_logs), + ip in _get_banned_ips(), json.dumps(result) + )) + conn.commit() + result["saved"] = True + except Exception as e: + result["saved"] = False + result["save_error"] = str(e) + return jsonify(result) +def _get_banned_ips(): + """Quick check of all banned IPs.""" + import subprocess + banned = set() + for jail in ["llm-team-exploit", "llm-team-login"]: + try: + r = subprocess.run(["fail2ban-client", "status", jail], capture_output=True, text=True, timeout=5) + for line in r.stdout.split("\n"): + if "Banned IP list" in line: + for ip in line.split(":", 1)[1].strip().split(): + banned.add(ip.strip()) + except Exception: + pass + return banned + + +@app.route("/api/admin/wall-of-shame") +@admin_required +def admin_wall_of_shame(): + """Return all enriched threat intel from the database.""" + sort = request.args.get("sort", "enriched_at") + order = request.args.get("order", "desc") + threat_filter = request.args.get("threat", "") + allowed_sorts = {"enriched_at", "threat_level", "confidence", "blocklist_count", "log_count", "ip"} + if sort not in allowed_sorts: + sort = "enriched_at" + order_sql = "DESC" if order == "desc" else "ASC" + try: + with get_db() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + if threat_filter: + cur.execute(f"SELECT * FROM threat_intel WHERE threat_level = %s ORDER BY {sort} {order_sql} LIMIT 200", (threat_filter,)) + else: + cur.execute(f"SELECT * FROM threat_intel ORDER BY {sort} {order_sql} LIMIT 200") + rows = cur.fetchall() + for r in rows: + r["enriched_at"] = r["enriched_at"].isoformat() if r["enriched_at"] else None + r["updated_at"] = r["updated_at"].isoformat() if r["updated_at"] else None + return jsonify({"entries": rows, "total": len(rows)}) + except Exception as e: + return jsonify({"entries": [], "error": str(e)}) + + @app.route("/api/admin/security/mass-ban", methods=["POST"]) @admin_required def admin_mass_ban():