Files

344 lines
18 KiB
Python

#!/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
import shutil
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>&larr; prev</button>
<span class="pill" id="page">page 1</span>
<button id="next">next &rarr;</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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>';}}
let lastBytes=-1,lastKey='';
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});
const key=p.toString();
const r=await fetch('/api/events?'+p);const d=await r.json();
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);
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();});
let qTimer;document.getElementById('q').addEventListener('input',()=>{clearTimeout(qTimer);qTimer=setTimeout(reset,300);});
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
# Cheap raw-bytes prefilter: skip JSON parsing for lines that cannot match (event/q/peer/payload).
event_token = ('"event": "%s"' % kind).encode() if kind else None
payload_token = b'"evidence"' if payload_only else None
if path.is_file():
# ponytail: still O(file) for a rare term on a multi-GB journal; add a sqlite FTS5 index
# (stdlib, incremental) only once the journal reaches a few hundred MB.
for raw in reverse_lines(path):
scanned += 1
low = raw.lower() if (terms or peer) else None
if terms and any(term.encode() not in low for term in terms):
continue
if peer and peer.encode() not in low:
continue
if event_token and event_token not in raw:
continue
if payload_token and payload_token not in raw:
continue
try:
row = json.loads(raw)
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]
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
body = json.dumps({'events': page_rows, 'scanned': scanned, 'page': page, 'size': size,
'has_more': len(rows) >= need, 'hidden': len(hidden),
'hidden_ips': sorted(hidden), 'journal_bytes': journal_bytes,
'journal_mtime': journal_mtime}, ensure_ascii=True).encode()
self.send(200, body, 'application/json', (('Content-Disposition', 'inline'),))
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)
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')
download = None if self.one(query, 'inline', '0') == '1' else f'{sha}.bin'
self.send_stream(path, download)
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()