309 lines
16 KiB
Python
309 lines
16 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 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>';}}
|
||
|
|
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 r=await fetch('/api/events?'+p);const d=await r.json();
|
||
|
|
current=d.events;lastScanned=d.scanned;lastHidden=d.hidden;hasMore=d.has_more;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();});
|
||
|
|
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]
|
||
|
|
body = json.dumps({'events': page_rows, 'scanned': scanned, 'page': page, 'size': size,
|
||
|
|
'has_more': len(rows) >= need, 'hidden': len(hidden)}, ensure_ascii=True).encode()
|
||
|
|
self.send(200, body, 'application/json', (('Content-Disposition', 'inline'),))
|
||
|
|
|
||
|
|
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')
|
||
|
|
data = path.read_bytes()
|
||
|
|
if self.one(query, 'inline', '0') == '1':
|
||
|
|
return self.send(200, data, 'application/octet-stream')
|
||
|
|
self.send(200, data, 'application/octet-stream',
|
||
|
|
(('Content-Disposition', f'attachment; filename="{sha}.bin"'),))
|
||
|
|
|
||
|
|
|
||
|
|
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()
|