#!/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
| time (UTC) | event | peer | summary |
|---|
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()