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:
root 2026-03-26 03:52:34 -05:00
parent e7f12a6d93
commit 418da99fa7

View File

@ -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():