#!/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''' CitrixPot analyst console

CitrixPot

page 1
time (UTC)eventpeersummary

Select an event

Payload

no payload in this event

Raw event

-
''' 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()