2026-09-28 19:44:37 +02:00
#!/usr/bin/env python3
"""Read-only local evidence viewer. Never exposed to attacker traffic; loopback by default."""
import argparse
import http.server
import ipaddress
import json
import os
import pathlib
import re
2026-09-28 20:11:19 +02:00
import shutil
2026-09-28 19:44:37 +02:00
import threading
import time
import urllib.parse
from env_file import load , env_int
PAGE = r '''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>CitrixPot analyst console</title>
<style>
:root{color-scheme:dark}*{box-sizing:border-box}
body{margin:0;font:12.5px/1.45 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#0b0e13;color:#d7dce3}
header{position:sticky;top:0;background:#121722;border-bottom:1px solid #222c3a;padding:8px 12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;z-index:5}
h1{font-size:13px;margin:0 8px 0 0;font-weight:700;color:#fff;letter-spacing:.3px}
input,select,button{background:#0b0e13;color:#d7dce3;border:1px solid #2c3646;border-radius:6px;padding:5px 8px;font:inherit}
input#q{min-width:300px}button{cursor:pointer}button:hover:not(:disabled){border-color:#4b5a6b}button:disabled{opacity:.4;cursor:default}
.pill{font-size:11px;color:#9aa7b6;background:#17202c;border-radius:999px;padding:3px 9px;display:inline-flex;gap:5px;align-items:center}
main{display:grid;grid-template-columns:minmax(420px,1fr) minmax(460px,1.1fr);height:calc(100vh - 50px)}
#list{overflow:auto;border-right:1px solid #222c3a}
table{border-collapse:collapse;width:100%;table-layout:fixed}
th,td{text-align:left;padding:5px 9px;border-bottom:1px solid #161c25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
th{position:sticky;top:0;background:#121722;color:#8896a6;font-weight:600;z-index:1;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
tr{cursor:pointer}tr:hover{background:#141b25}tr.sel{background:#1b2736}
td.t{color:#78869a;width:98px}td.e{font-weight:600;color:#6fb6d8;width:150px}td.p{color:#9aa7b6;width:128px}
#detail{overflow:auto;padding:12px 14px}
#detail h2{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:#8896a6;margin:14px 0 6px;border-bottom:1px solid #222c3a;padding-bottom:4px}
pre{white-space:pre-wrap;word-break:break-all;background:#070a0e;border:1px solid #222c3a;border-radius:8px;padding:10px;font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;margin:0;max-height:46vh;overflow:auto}
a{color:#6fb6d8}#artbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:6px}
@media(max-width:980px){main{grid-template-columns:1fr}#detail{display:none}}
</style></head><body>
<header>
<h1>CitrixPot</h1>
<input id="q" placeholder="full-text search: all terms must match (command, path, header, filename, IP...)">
<select id="event"><option value="">all events</option></select>
<input id="peer" placeholder="peer IP" style="width:110px">
<label class="pill"><input type="checkbox" id="payload"> payloads only</label>
<label class="pill"><input type="checkbox" id="hide" checked> hide our IPs</label>
<label class="pill"><input type="checkbox" id="live" checked> live</label>
<select id="size"><option>50</option><option selected>100</option><option>250</option><option>500</option></select>
<button id="prev" disabled>← prev</button>
<span class="pill" id="page">page 1</span>
<button id="next">next →</button>
<button id="reload">refresh</button>
<span class="pill" id="stats"></span>
</header>
<main>
<div id="list"><table><thead><tr><th>time (UTC)</th><th>event</th><th>peer</th><th>summary</th></tr></thead><tbody id="rows"></tbody></table></div>
<div id="detail"><p id="meta2" class="pill">Select an event</p>
<div id="art"><h2>Payload</h2><pre>no payload in this event</pre></div>
<h2>Raw event</h2><pre id="json">-</pre></div>
</main>
<script>
'use strict';
const rows=document.getElementById('rows'),jsonBox=document.getElementById('json'),art=document.getElementById('art');
let current=[],selected=-1,payloadBytes=null,page=0,hasMore=false;
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const FIELDS=['command','argv','path','uri','filename','destination_ip','status','resource','reason','name','socket','value'];
function summary(e){for(const k of FIELDS){if(e[k]!=null)return esc(Array.isArray(e[k])?e[k].join(' '):e[k]);}
if(Array.isArray(e.headers)){const ua=e.headers.find(x=>String(x[0]).toLowerCase()==='user-agent');if(ua)return esc(ua[1]);}
if(e.error)return esc(e.error);return '';}
function render(list){
rows.innerHTML=list.map((e,i)=>`<tr data-i="$ {i} "><td class="t">${esc((e.timestamp||'').slice(11,23))}</td><td class="e">${esc(e.event)}</td><td class="p">${esc(e.peer||'')}</td><td>${summary(e)}</td></tr>`).join('');
document.querySelectorAll('#rows tr').forEach(tr=>tr.onclick=()=>select(+tr.dataset.i));
const kinds=[...new Set(list.map(e=>e.event))].sort();
const sel=document.getElementById('event'),keep=sel.value;
for(const k of kinds)if(![...sel.options].some(o=>o.value===k))sel.insertAdjacentHTML('beforeend',`<option>${esc(k)}</option>`);
sel.value=keep;
const counts= {} ;for(const e of list)counts[e.event]=(counts[e.event]||0)+1;
document.getElementById('stats').textContent=`$ {list.length} rows · scanned $ {lastScanned} · hiding $ {lastHidden} our IPs · ${counts['injection_candidate']||0} inj · ${counts['brute_force_suspected']||0} bf`;
}
let lastScanned=0,lastHidden=0;
function printable(bytes){if(!bytes.length)return true;let p=0;for(const c of bytes){if(c===9||c===10||c===13||(c>=32&&c<127)||c>=160)p++;}return p/bytes.length>0.9;}
function hexdump(bytes,max){let out='';for(let o=0;o<Math.min(bytes.length,max);o+=16){let hex='',asc='';
for(let j=0;j<16;j++){if(o+j<bytes.length){const c=bytes[o+j];hex+=c.toString(16).padStart(2,'0')+' ';asc+=c>=32&&c<127?String.fromCharCode(c):'.';}else hex+=' ';}
out+=o.toString(16).padStart(8,'0')+' '+hex+' |'+asc+'|\n';}
if(bytes.length>max)out+=`\n... ${bytes.length-max} more bytes (download for full)`;return out;}
function showPayload(mode){if(payloadBytes===null)return;const b=document.getElementById('pbody');if(!b)return;
if(mode==='hex')b.textContent=hexdump(payloadBytes,65536);
else if(printable(payloadBytes))b.textContent=new TextDecoder().decode(payloadBytes.slice(0,262144));
else b.textContent='(binary; showing hex)\n\n'+hexdump(payloadBytes,65536);}
async function loadPayload(sha){payloadBytes=null;
const r=await fetch('/api/artifact/'+sha+'?inline=1');
if(!r.ok){art.innerHTML='<h2>Payload</h2><pre>artifact unavailable</pre>';return;}
payloadBytes=new Uint8Array(await r.arrayBuffer());
art.innerHTML=`<h2>Payload</h2><div id="artbar"><span class="pill">$ {payloadBytes.length} bytes · ${sha.slice(0,16)}…</span>
<button onclick="showPayload('text')">text</button><button onclick="showPayload('hex')">hex</button>
<a class="pill" href="/api/artifact/$ {sha} ">download</a></div><pre id="pbody"></pre>`;
showPayload(printable(payloadBytes)?'text':'hex');}
function select(i){selected=i;
document.querySelectorAll('#rows tr').forEach(tr=>tr.classList.toggle('sel',+tr.dataset.i===i));
const e=current[i];
document.getElementById('meta2').textContent=[e.event,e.timestamp,e.peer,e.source].filter(Boolean).join(' · ');
jsonBox.textContent=JSON.stringify(e,null,2);
if(e.evidence&&e.evidence.sha256)loadPayload(e.evidence.sha256);
else{payloadBytes=null;art.innerHTML='<h2>Payload</h2><pre>no payload in this event</pre>';}}
2026-09-28 20:11:19 +02:00
let lastBytes=-1,lastKey='';
2026-09-28 19:44:37 +02:00
async function load(){
const p=new URLSearchParams({q:document.getElementById('q').value,event:document.getElementById('event').value,
peer:document.getElementById('peer').value,payload:document.getElementById('payload').checked?'1':'0',
hide:document.getElementById('hide').checked?'1':'0',
size:document.getElementById('size').value,page:page});
2026-09-28 20:11:19 +02:00
const key=p.toString();
2026-09-28 19:44:37 +02:00
const r=await fetch('/api/events?'+p);const d=await r.json();
2026-09-28 20:11:19 +02:00
lastScanned=d.scanned;lastHidden=d.hidden;hasMore=d.has_more;
// Skip re-render when nothing changed on the live page (cheap 3s refresh).
if(page===0 && d.journal_bytes===lastBytes && key===lastKey){return;}
lastBytes=d.journal_bytes;lastKey=key;
current=d.events;render(current);
2026-09-28 19:44:37 +02:00
document.getElementById('page').textContent='page '+(page+1);
document.getElementById('prev').disabled=page<=0;
document.getElementById('next').disabled=!hasMore;}
function reset(){page=0;load();}
document.getElementById('reload').onclick=reset;
document.getElementById('prev').onclick=()=>{if(page>0){page--;load();}};
document.getElementById('next').onclick=()=>{if(hasMore){page++;load();}};
document.getElementById('q').addEventListener('keydown',e=>{if(e.key==='Enter')reset();});
2026-09-28 20:11:19 +02:00
let qTimer;document.getElementById('q').addEventListener('input',()=>{clearTimeout(qTimer);qTimer=setTimeout(reset,300);});
2026-09-28 19:44:37 +02:00
document.getElementById('peer').addEventListener('keydown',e=>{if(e.key==='Enter')reset();});
document.getElementById('event').onchange=reset;
document.getElementById('payload').onchange=reset;
document.getElementById('hide').onchange=reset;
document.getElementById('size').onchange=reset;
document.addEventListener('keydown',e=>{if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT')return;
if(e.key==='j'||e.key==='ArrowDown'){e.preventDefault();select(Math.min(current.length-1,selected+1));}
if(e.key==='k'||e.key==='ArrowUp'){e.preventDefault();select(Math.max(0,selected-1));}});
setInterval(()=>{if(page===0&&document.getElementById('live').checked)load();},3000);
load();
</script></body></html>'''
def reverse_lines ( path , block = 1 << 20 ):
"""Yield file lines newest-first without loading the whole file."""
with path . open ( 'rb' ) as stream :
stream . seek ( 0 , 2 )
position = stream . tell ()
tail = b ''
while position > 0 :
size = min ( block , position )
position -= size
stream . seek ( position )
lines = ( stream . read ( size ) + tail ) . split ( b ' \n ' )
tail = lines [ 0 ]
for line in reversed ( lines [ 1 :]):
if line :
yield line
if tail . strip ():
yield tail
def artifact_path ( artifact_dir , sha ):
if not re . fullmatch ( r '[0-9a-f] {64} ' , sha or '' ):
return None
path = artifact_dir / ( sha + '.bin' )
return path if path . is_file () else None
class Handler ( http . server . BaseHTTPRequestHandler ):
server_version = 'CitrixPotViewer'
sys_version = ''
protocol_version = 'HTTP/1.1'
def log_message ( self , * args ):
pass
def send ( self , code , content , mime = 'application/octet-stream' , extra = ()):
self . send_response ( code )
self . send_header ( 'Content-Type' , mime )
self . send_header ( 'Content-Length' , str ( len ( content )))
self . send_header ( 'X-Content-Type-Options' , 'nosniff' )
self . send_header ( 'Cache-Control' , 'no-store' )
for key , value in extra :
self . send_header ( key , value )
self . end_headers ()
if self . command != 'HEAD' :
self . wfile . write ( content )
def do_GET ( self ):
url = urllib . parse . urlsplit ( self . path )
query = urllib . parse . parse_qs ( url . query )
if url . path == '/' :
return self . send ( 200 , PAGE . encode (), 'text/html; charset=utf-8' )
if url . path == '/api/events' :
return self . events ( query )
if url . path . startswith ( '/api/artifact/' ):
return self . artifact ( url . path . rsplit ( '/' , 1 )[ - 1 ], query )
self . send ( 404 , b 'not found' , 'text/plain; charset=utf-8' )
def do_HEAD ( self ):
self . do_GET ()
@staticmethod
def one ( query , name , default = '' ):
return query . get ( name , [ default ])[ 0 ]
def events ( self , query ):
try :
size = min ( max ( int ( self . one ( query , 'size' , '100' )), 1 ), 500 )
except ValueError :
size = 100
try :
page = max ( int ( self . one ( query , 'page' , '0' )), 0 )
except ValueError :
page = 0
terms = [ term for term in ( self . one ( query , 'q' ) or '' ) . lower () . split () if term ]
kind = self . one ( query , 'event' )
peer = ( self . one ( query , 'peer' ) or '' ) . lower ()
payload_only = self . one ( query , 'payload' , '0' ) == '1'
hidden = getattr ( self . server , 'hide_ips' , set ()) if self . one ( query , 'hide' , '1' ) != '0' else set ()
need = ( page + 1 ) * size
rows , scanned = [], 0
path = self . server . journal
if path . is_file ():
# ponytail: reverse scan is bounded by page depth, not file size, for unfiltered paging;
# add a sqlite FTS index only if filtered deep paging on multi-GB journals gets slow.
for raw in reverse_lines ( path ):
scanned += 1
line = raw . decode ( 'utf-8' , 'replace' )
low = line . lower ()
if any ( term not in low for term in terms ):
continue
try :
row = json . loads ( line )
except ValueError :
continue
if kind and row . get ( 'event' ) != kind :
continue
if row . get ( 'peer' ) in hidden :
continue
if peer and peer not in str ( row . get ( 'peer' , '' )) . lower ():
continue
if payload_only and not row . get ( 'evidence' ):
continue
rows . append ( row )
if len ( rows ) >= need :
break
page_rows = rows [ page * size :( page + 1 ) * size ]
2026-09-28 20:11:19 +02:00
try :
stat = self . server . journal . stat ()
journal_bytes , journal_mtime = stat . st_size , int ( stat . st_mtime )
except OSError :
journal_bytes , journal_mtime = 0 , 0
2026-09-28 19:44:37 +02:00
body = json . dumps ({ 'events' : page_rows , 'scanned' : scanned , 'page' : page , 'size' : size ,
2026-09-28 19:48:50 +02:00
'has_more' : len ( rows ) >= need , 'hidden' : len ( hidden ),
2026-09-28 20:11:19 +02:00
'hidden_ips' : sorted ( hidden ), 'journal_bytes' : journal_bytes ,
'journal_mtime' : journal_mtime }, ensure_ascii = True ) . encode ()
2026-09-28 19:44:37 +02:00
self . send ( 200 , body , 'application/json' , (( 'Content-Disposition' , 'inline' ),))
2026-09-28 20:11:19 +02:00
def send_stream ( self , path , download_name = None ):
# Stream large payloads without loading them into memory.
self . send_response ( 200 )
self . send_header ( 'Content-Type' , 'application/octet-stream' )
self . send_header ( 'Content-Length' , str ( path . stat () . st_size ))
self . send_header ( 'X-Content-Type-Options' , 'nosniff' )
self . send_header ( 'Cache-Control' , 'no-store' )
if download_name :
self . send_header ( 'Content-Disposition' , f 'attachment; filename=" { download_name } "' )
self . end_headers ()
if self . command != 'HEAD' :
with path . open ( 'rb' ) as stream :
shutil . copyfileobj ( stream , self . wfile , length = 1 << 20 )
2026-09-28 19:44:37 +02:00
def artifact ( self , sha , query ):
path = artifact_path ( self . server . artifacts , sha )
if path is None :
return self . send ( 404 , b 'not found' , 'text/plain; charset=utf-8' )
2026-09-28 20:11:19 +02:00
download = None if self . one ( query , 'inline' , '0' ) == '1' else f ' { sha } .bin'
self . send_stream ( path , download )
2026-09-28 19:44:37 +02:00
def main ():
load ()
parser = argparse . ArgumentParser ( description = __doc__ )
parser . add_argument ( '--log-dir' , default = os . environ . get ( 'VIEWER_LOG_DIR' , str ( pathlib . Path ( __file__ ) . parent / 'logs' )))
parser . add_argument ( '--bind' , default = os . environ . get ( 'VIEWER_BIND' , '127.0.0.1' ))
parser . add_argument ( '--port' , type = int , default = env_int ( 'VIEWER_PORT' , 8787 ))
parser . add_argument ( '--allow-non-loopback' , action = 'store_true' ,
help = 'required to bind anything other than a loopback address' )
parser . add_argument ( '--hide-ip' , action = 'append' , metavar = 'IP' ,
default = [ item . strip () for item in os . environ . get ( 'VIEWER_HIDE_IP' , '' ) . split ( ',' ) if item . strip ()],
help = 'extra client IP to hide (repeatable; VIEWER_HIDE_IP is comma-separated)' )
parser . add_argument ( '--hide-ip-file' , help = 'file with one hidden IP per line' )
parser . add_argument ( '--no-hide-public-ip' , action = 'store_true' ,
help = "do not auto-detect and hide this host's public IP" )
parser . add_argument ( '--hide-refresh' , type = int , default = 1800 ,
help = 'seconds between public-IP refreshes (default 1800)' )
args = parser . parse_args ()
try :
loopback = ipaddress . ip_address ( args . bind ) . is_loopback
except ValueError :
loopback = args . bind == 'localhost'
if not loopback and not args . allow_non_loopback :
parser . error ( 'refusing non-loopback bind without --allow-non-loopback' )
directory = pathlib . Path ( args . log_dir )
server = http . server . ThreadingHTTPServer (( args . bind , args . port ), Handler )
server . journal = directory / 'events.jsonl'
server . artifacts = directory / 'artifacts'
server . hide_ips = { ip for ip in args . hide_ip if ip }
def hide_operator_traffic ():
from public_ip import discover
while True :
if args . hide_ip_file :
try :
server . hide_ips . update ( line . strip () for line in
pathlib . Path ( args . hide_ip_file ) . read_text () . splitlines () if line . strip ())
except OSError :
pass
if not args . no_hide_public_ip :
ip = discover ()
if ip :
server . hide_ips . add ( ip )
time . sleep ( args . hide_refresh )
threading . Thread ( target = hide_operator_traffic , daemon = True ) . start ()
print ( f 'Evidence viewer: http:// { args . bind } : { server . server_port } / (read-only; { directory } )' , flush = True )
try :
server . serve_forever ()
except KeyboardInterrupt :
pass
finally :
server . server_close ()
if __name__ == '__main__' :
main ()