Files
OpenFUT/scripts/test-utas-observe.py
T
funman300 0b66662525 utas: first real corpus, and two sanitiser gaps the audit caught
24 transactions across 11 connections from a retail session: login,
hub, one pack open, two squad saves, a quick-sell, with before/after
state manifests. Raw .ofcap stays gitignored at 0600; the sanitized
corpus is committed as adapter fixtures.

TWO GAPS FOUND BY AUDITING THE OUTPUT, NOT BY TRUSTING THE SANITISER.

1. `POST /ut/auth` carries `macAddress` and `deviceId`. Session tokens
   were being redacted correctly and these were not. A committed fixture
   is a published fixture.

2. Then, with those fixed, the audit fired AGAIN on the file about to be
   committed: `GET .../phishing/trusteddevice?deviceId=...` puts the id in
   the QUERY STRING. Three input surfaces carry identifiers -- headers,
   JSON bodies, and query strings -- and the sanitiser knew about two.

Both fixed in the tool rather than by editing the file, with a
regression test and a mutation for the query path.

AND A THIRD ARTEFACT MIX-UP, in the mutation harness itself. It reported
the query-redaction mutation as SURVIVED while a hand-run of the same
mutation killed it. Cause: the harness pointed at a stale scratchpad copy
of the test that pre-dated the query assertion, so it was faithfully
testing the mutated tool against a test that could not detect the
mutation. That is the same class as the build guard checking the wrong
binary and cargo reusing a binary compiled from mutated source -- the
third instance today of measuring the wrong artifact. The harness now
resolves ROOT from its own location and runs the COMMITTED test; the
stale copy is deleted.

Harness committed as scripts/mutate-utas-observe.py so this is repeatable
rather than a thing that happened once in a scratch directory. 6/6 killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:33:59 +00:00

221 lines
8.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Prove the UTAS recorder is byte-transparent and parses what it saw.
Two independent properties, because they fail differently:
TRANSPARENCY what the client receives through the proxy is byte-identical to
what it receives going direct. If this breaks, the tool corrupts
the session it exists to observe -- and the damage would look
like a UTAS bug.
FIDELITY the parsed transactions match what was actually sent. If this
breaks, only the record is wrong, which is why the design
separates the two.
The upstream here is a purpose-built server, not the live oracle: it can be made
to exercise keep-alive, bodies in both directions and chunked encoding on
demand, and no live client is at risk.
"""
import base64
import http.server
import json
import os
import shutil
import socket
import subprocess
import sys
import threading
import time
TOOL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "openfut-utas-observe.py")
SESSION = os.path.join(os.environ.get("TMPDIR", "/tmp"), "openfut-utas-observe-selftest")
fails = []
def check(name, ok, detail=""):
print((" ok " if ok else " FAIL ") + name + ((" " + detail) if detail and not ok else ""))
if not ok:
fails.append(name)
class H(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # keep-alive, like the real UTAS
def log_message(self, *a):
pass
def _read_body(self):
n = int(self.headers.get("Content-Length", 0) or 0)
return self.rfile.read(n) if n else b""
def do_GET(self):
if self.path.split("?")[0] == "/chunked":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
for part in (b'{"a":1,', b'"b":[2,3],', b'"c":"end"}'):
self.wfile.write(b"%x\r\n" % len(part) + part + b"\r\n")
self.wfile.write(b"0\r\n\r\n")
return
body = json.dumps({"path": self.path}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
got = self._read_body()
body = json.dumps({"echo_len": len(got),
"echo": got.decode("latin1"),
"sid": "SECRET-SESSION-VALUE"}).encode()
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
def raw_exchange(port, requests):
"""Send requests on ONE keep-alive connection; return all bytes received."""
s = socket.create_connection(("127.0.0.1", port), timeout=5)
for r in requests:
s.sendall(r)
time.sleep(0.15)
s.settimeout(1.5)
out = b""
try:
while True:
d = s.recv(65536)
if not d:
break
out += d
except socket.timeout:
pass
s.close()
return out
def main():
shutil.rmtree(SESSION, ignore_errors=True)
up_port, proxy_port = free_port(), free_port()
srv = http.server.ThreadingHTTPServer(("127.0.0.1", up_port), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
proxy = subprocess.Popen(
[sys.executable, TOOL, "record",
"--listen", "127.0.0.1:%d" % proxy_port,
"--upstream", "127.0.0.1:%d" % up_port,
"--session", SESSION],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
time.sleep(1.0)
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
reqs = [
b"GET /ut/game/fifa17/userMassInfo HTTP/1.1\r\nHost: t\r\n\r\n",
b"POST /ut/game/fifa17/purchased/items HTTP/1.1\r\nHost: t\r\n"
b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body,
b"GET /chunked?deviceId=DEADBEEFCAFE&keep=yes HTTP/1.1\r\nHost: t\r\n\r\n",
b"GET /ut/game/fifa17/hub HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n",
]
print("== transparency ==")
direct = raw_exchange(up_port, reqs)
through = raw_exchange(proxy_port, reqs)
# The two exchanges happen seconds apart, so `Date:` legitimately differs.
# Mask it -- but ONLY it, and assert the mask actually fired, so this cannot
# quietly hide a real difference.
import re as _re
def mask(b):
return _re.sub(rb"Date: [^\r\n]+", b"Date: <M>", b)
dm, tm = mask(direct), mask(through)
check("masking Date actually applied", dm != direct and tm != through)
check("bytes through the proxy are identical to bytes direct (Date masked)",
dm == tm,
"direct=%dB through=%dB firstdiff=%s" % (
len(direct), len(through),
next((i for i in range(min(len(dm), len(tm))) if dm[i] != tm[i]), "len")))
check("nothing but Date differed in the unmasked bytes",
len(direct) == len(through))
check("all four responses arrived", through.count(b"HTTP/1.1 ") == 4,
"saw %d" % through.count(b"HTTP/1.1 "))
check("keep-alive was actually used (one connection, four responses)",
through.count(b"HTTP/1.1 ") == 4)
time.sleep(0.6)
proxy.terminate()
try:
proxy.wait(timeout=5)
except subprocess.TimeoutExpired:
proxy.kill()
print("== capture file ==")
raw = os.path.join(SESSION, "raw", "utas.ofcap")
check("raw capture exists", os.path.exists(raw))
check("raw capture is mode 0600",
oct(os.stat(raw).st_mode & 0o777) == "0o600",
oct(os.stat(raw).st_mode & 0o777))
print("== fidelity ==")
r = subprocess.run([sys.executable, TOOL, "parse", "--session", SESSION],
capture_output=True, text=True)
print(" " + r.stdout.strip().replace("\n", "\n "))
if r.stderr.strip():
print(" stderr: " + r.stderr.strip().replace("\n", "\n "))
txs = [json.loads(l) for l in
open(os.path.join(SESSION, "sanitized", "transactions.jsonl"))]
# Two client connections were made (direct + through); only one went via the proxy.
check("four transactions parsed", len(txs) == 4, "got %d" % len(txs))
if len(txs) == 4:
check("methods and paths in order",
[(t["request"]["method"], t["request"]["path"]) for t in txs] ==
[("GET", "/ut/game/fifa17/userMassInfo"),
("POST", "/ut/game/fifa17/purchased/items"),
("GET", "/chunked"),
("GET", "/ut/game/fifa17/hub")])
check("request body preserved byte-for-byte",
base64.b64decode(txs[1]["request"]["body_b64"]) == body)
check("statuses recorded", [t["response"]["status"] for t in txs] == [200, 201, 200, 200])
chunked = base64.b64decode(txs[2]["response"]["body_b64"])
check("chunked response body dechunked correctly",
chunked == b'{"a":1,"b":[2,3],"c":"end"}', chunked[:60].decode("latin1"))
check("headers kept in received order, as pairs",
txs[0]["response"]["headers"][0][0] == "Server")
check("keep-alive recorded", txs[0]["connection"]["requests_on_this_connection"] == 4)
check("timing recorded", all(t["elapsed_ms"] is not None for t in txs))
# Sanitiser: the secret goes, the rest of the body does not.
echoed = json.loads(base64.b64decode(txs[1]["response"]["body_b64"]))
check("secret JSON value redacted", echoed.get("sid") == "<REDACTED>")
check("non-secret payload preserved exactly", echoed.get("echo_len") == len(body))
check("redaction is recorded, not silent", "redacted" in txs[1])
# Regression: the sanitiser handled headers and JSON bodies but not the
# QUERY STRING, and was one audit away from publishing a device id from
# `?deviceId=...`. Three surfaces carry identifiers, not two.
qtx = next((t for t in txs if t["request"]["query"]), None)
check("a secret query parameter is redacted",
qtx is not None and "deviceId=<REDACTED>" in qtx["request"]["query"],
qtx["request"]["query"] if qtx else "no query captured")
check("a non-secret query parameter is preserved",
qtx is not None and "keep=yes" in qtx["request"]["query"],
qtx["request"]["query"] if qtx else "")
srv.shutdown()
print()
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())