Wall of Shame: persistent threat intel database with drill-down table
Database: - threat_intel table with full enrichment data per IP - UPSERT on IP — re-enriching updates existing record - Stores: geo, AI analysis, web-check results, indicators, raw JSON - Indexed on IP (unique), threat_level, enriched_at Auto-save: - Every enrichment auto-saves to DB (step 5 in enrichment pipeline) - "Saved to Wall of Shame database" indicator in enrichment panel - No duplicate scans — re-enrich updates the existing record Wall of Shame tab (/logs): - Stats bar: Total Profiled, Critical, High, Proxies, Automated - Sortable table: IP, Threat, Type, Summary, Country, Ports - Click any row to expand full detail: ISP, Org, ASN, City, Proxy/Hosting flags, Confidence, Blocklist count, Pattern, Recommendation, Indicators - All data persists across restarts — no re-scanning needed API: - /api/admin/wall-of-shame — list all enriched IPs with sorting/filtering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7f12a6d93
commit
418da99fa7
255
llm_team_ui.py
255
llm_team_ui.py
@ -606,6 +606,7 @@ h1 span{color:var(--accent)}
|
||||
<div class="tab" data-src="nginx_access" onclick="switchTab(this)">Nginx Access</div>
|
||||
<div class="tab err" data-src="security" onclick="switchTab(this)">Security Raw</div>
|
||||
<div class="tab err" data-src="threats" onclick="switchTab(this)">Threat Intel</div>
|
||||
<div class="tab" data-src="shame" onclick="switchTab(this)" style="color:#d946ef;border-color:rgba(217,70,239,0.3)">Wall of Shame</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>Lines:</label>
|
||||
@ -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);
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
@ -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():
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user