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>
This commit is contained in:
funman300
2026-08-11 18:33:59 +00:00
parent cdea85e214
commit 0b66662525
6 changed files with 405 additions and 50 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
{
"routes": {
"accountInfo": {
"body_len": 2,
"fields": {
"keys": []
},
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"status": 200
},
"activeSquad": {
"body_len": 8034,
"fields": {
"formation": "f442",
"id": 0,
"players.count": 23,
"slots": {
"0": 100000003,
"1": 100000006,
"10": 100000010,
"11": 0,
"12": 0,
"13": 0,
"14": 0,
"15": 0,
"16": 0,
"17": 0,
"18": 0,
"19": 0,
"2": 100000005,
"20": 0,
"21": 0,
"22": 0,
"3": 100000008,
"4": 100000007,
"5": 100000002,
"6": 100000004,
"7": 100000009,
"8": 100000001,
"9": 100000025
}
},
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
"status": 200
},
"credits": {
"body_len": 148,
"fields": {
"credits": 28112944
},
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
"status": 200
},
"tradePile": {
"body_len": 862,
"fields": {
"auctionInfo.count": 1
},
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
"status": 200
},
"unassigned": {
"body_len": 16,
"fields": {
"itemData.count": 0
},
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
"status": 200
},
"userMassInfo": {
"body_len": 8929,
"fields": {
"clubAbbr": "OFC",
"clubName": "OpenFUT",
"personaId": 33068179,
"trophies": 0
},
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
"status": 200
}
},
"unix": 1786472465.339646,
"upstream": "127.0.0.1:8099"
}
@@ -0,0 +1,84 @@
{
"routes": {
"accountInfo": {
"body_len": 2,
"fields": {
"keys": []
},
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"status": 200
},
"activeSquad": {
"body_len": 8034,
"fields": {
"formation": "f442",
"id": 0,
"players.count": 23,
"slots": {
"0": 100000003,
"1": 100000010,
"10": 100000001,
"11": 100000002,
"12": 0,
"13": 0,
"14": 0,
"15": 0,
"16": 0,
"17": 0,
"18": 0,
"19": 0,
"2": 100000009,
"20": 0,
"21": 0,
"22": 0,
"3": 100000008,
"4": 100000007,
"5": 100000006,
"6": 100000005,
"7": 100000004,
"8": 100000025,
"9": 0
}
},
"sha256": "768bb0584ad953ac5f088ad029f161d302ee1be3a63826eb186405acaf1b6232",
"status": 200
},
"credits": {
"body_len": 148,
"fields": {
"credits": 28020656
},
"sha256": "8d39fee51c24ddee9f12d98d7833af6cd3d3399a0e7567ab7062945a1180e30f",
"status": 200
},
"tradePile": {
"body_len": 862,
"fields": {
"auctionInfo.count": 1
},
"sha256": "1575046afb8ca60c76de64427ee0c70e1d4ca2da032819a60db95d72d272d11d",
"status": 200
},
"unassigned": {
"body_len": 16,
"fields": {
"itemData.count": 0
},
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
"status": 200
},
"userMassInfo": {
"body_len": 8929,
"fields": {
"clubAbbr": "OFC",
"clubName": "OpenFUT",
"personaId": 33068179,
"trophies": 0
},
"sha256": "3d89d0497661fc62f107081208a14c4fa5753ee4e6482eeda825fe4b622f871f",
"status": 200
}
},
"unix": 1786472102.908128,
"upstream": "127.0.0.1:8099"
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Mutation-test the UTAS recorder. A no-op replacement exits 3 rather than
counting as a survivor -- a sed that silently does nothing is how a mutation run
lies about coverage."""
import os, shutil, subprocess, sys
import os as _os
ROOT = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
# The COMMITTED test, not a scratchpad copy. Pointing this at a stale
# duplicate made a live mutation look survivable: the old copy had no
# query-string assertion, so disabling query redaction passed.
TEST = ROOT + "/scripts/test-utas-observe.py"
MUTATIONS = [
("U1 tee: record but do not forward",
"scripts/openfut-utas-observe.py",
" try:\n dst.sendall(data)",
" try:\n pass"),
("U2 tee: drop the last byte of every chunk",
"scripts/openfut-utas-observe.py",
" dst.sendall(data)",
" dst.sendall(data[:-1])"),
("U3 sanitiser: stop redacting secrets",
"scripts/openfut-utas-observe.py",
" if any(s in k.lower() for s in SECRET_BODY_KEYS) and isinstance(o[k], str):",
" if False:"),
("U4 parser: ignore chunked framing",
"scripts/openfut-utas-observe.py",
' if te and "chunked" in te.group(1).lower():',
" if False:"),
("U6 sanitiser: stop redacting query parameters",
"scripts/openfut-utas-observe.py",
" if eq and any(x in k.lower() for x in SECRET_BODY_KEYS):",
" if False:"),
("U5 parser: reverse header order",
"scripts/openfut-utas-observe.py",
" out.append([k, v.strip()])",
" out.insert(0, [k, v.strip()])"),
]
LAST = {}
def run():
r = subprocess.run([sys.executable, TEST], cwd=ROOT, capture_output=True, text=True)
LAST["rc"] = r.returncode
LAST["out"] = r.stdout
return r.returncode == 0
def main():
if not run():
print("BASELINE FAILS"); return 2
survivors = []
for name, rel, old, new in MUTATIONS:
path = os.path.join(ROOT, rel); bak = path + ".mutbak"
shutil.copy2(path, bak)
try:
src = open(path).read()
mutated = src.replace(old, new, 1)
if mutated == src:
print("!! MUTATION DID NOT APPLY: %s" % name); return 3
open(path, "w").write(mutated)
killed = not run()
print(("KILLED " if killed else "SURVIVED ") + name + " [rc=%s]" % LAST.get("rc"))
if not killed:
for l in LAST.get("out","").splitlines():
if "query" in l or "FAIL" in l or "all checks" in l:
print(" " + l)
if not killed: survivors.append(name)
finally:
shutil.move(bak, path); os.utime(path, None)
print()
print("all %d mutations killed" % len(MUTATIONS) if not survivors
else "%d SURVIVED: %s" % (len(survivors), survivors))
return 1 if survivors else 0
sys.exit(main())
+126 -48
View File
@@ -350,18 +350,48 @@ def header_list(head):
SECRET_HEADERS = {"authorization", "cookie", "set-cookie", "x-ut-sid", "easw-session-data-nucleus-id"}
SECRET_BODY_KEYS = ("sid", "token", "password", "answer", "secret")
# Substring-matched against lowercased JSON keys. Extended after auditing the
# first real corpus: session tokens were being redacted correctly, but the
# client also sends hardware and device identifiers in `POST /ut/auth`, and a
# committed fixture is a published fixture. Audit the output before publishing,
# every time -- "the sanitiser handles it" is a belief until it is checked.
SECRET_BODY_KEYS = (
"sid", "token", "password", "answer", "secret",
"macaddress", "deviceid", "authcode",
)
def sanitize(rec):
"""Repository-safe form. Bodies are preserved EXACTLY; only credentials go.
THREE input surfaces carry identifiers, not two. The first version handled
headers and JSON bodies and was still about to publish a device id, because
`GET /ut/game/fifa17/phishing/trusteddevice?deviceId=...` puts it in the
QUERY STRING. Caught by auditing the output rather than by trusting the
sanitiser -- which is the only reason it is a comment and not a leak.
Exact bodies first, sanitise second: the corpus is worthless if response
payloads are reshaped, so nothing here touches structure -- it replaces
known-secret header values and known-secret JSON keys, and records that it
did so.
"""
redacted = []
# Query string: same key rules as bodies.
q = rec["request"].get("query") or ""
if q:
parts, hit = [], False
for kv in q.split("&"):
k, eq, v = kv.partition("=")
if eq and any(x in k.lower() for x in SECRET_BODY_KEYS):
parts.append(k + "=<REDACTED>")
redacted.append("request.query." + k)
hit = True
else:
parts.append(kv)
if hit:
rec["request"]["query"] = "&".join(parts)
for side in ("request", "response"):
hs = rec[side]["headers"]
for pair in hs:
@@ -407,73 +437,121 @@ def redact_json(body):
# ────────────────────────────────────────────────────────────── snapshot ────
# Read-only GETs only. `/openfut/account/sync` is deliberately NOT here: it is a
# POST that selects and writes an account, so using it as a snapshot would
# mutate the state the snapshot exists to observe.
#
# All six were confirmed to answer 200 without a session token.
SNAPSHOT_ROUTES = [
("account", "/openfut/account/sync"),
("credits", "/ut/game/fifa17/user/credits"),
("userMassInfo", "/ut/game/fifa17/userMassInfo"),
("unassigned", "/ut/game/fifa17/purchased/items"),
("activeSquad", "/ut/game/fifa17/squad/active"),
("tradePile", "/ut/game/fifa17/tradePile"),
("accountInfo", "/ut/game/fifa17/user/accountinfo"),
]
def cmd_snapshot(args):
"""A light state manifest, for before/after pairing around a session.
def http_get(host, port, path):
s = socket.create_connection((host, int(port)), timeout=10)
s.sendall(("GET %s HTTP/1.1\r\nHost: %s:%s\r\nConnection: close\r\n\r\n"
% (path, host, port)).encode())
buf = b""
while True:
d = s.recv(65536)
if not d:
break
buf += d
s.close()
head, _, body = buf.partition(b"\r\n\r\n")
line = head.split(b"\r\n")[0].decode("latin1")
status = int(line.split()[1]) if len(line.split()) > 1 else None
return status, body
Deliberately a summary, not a database dump: the point is to say what
changed, and a committed copy of the save would be both huge and full of
things that are not evidence.
def cmd_snapshot(args):
"""A state manifest for pairing before/after around a capture session.
A summary plus a hash, not a database dump. The named fields are what a
human reads; the sha256 of each full body is the safety net, because a
summary can only report changes in fields somebody thought to list. If the
hash moves and no field does, the summary is incomplete -- and that is
itself a finding rather than a silent miss.
Raw bodies are written alongside at mode 0600 so a real diff is possible
later without re-running the session.
"""
import hashlib
uh, up = args.upstream.rsplit(":", 1)
out_path = os.path.abspath(args.out)
raw_dir = os.path.join(os.path.dirname(out_path), "bodies")
os.makedirs(raw_dir, exist_ok=True)
man = {"unix": time.time(), "upstream": args.upstream, "routes": {}}
for name, path in SNAPSHOT_ROUTES:
try:
s = socket.create_connection((uh, int(up)), timeout=5)
body = b'{"personaId":0}'
req = ("POST %s HTTP/1.1\r\nHost: %s:%s\r\nContent-Type: application/json\r\n"
"Content-Length: %d\r\nConnection: close\r\n\r\n" % (path, uh, up, len(body)))
s.sendall(req.encode() + body)
buf = b""
while True:
d = s.recv(4096)
if not d:
break
buf += d
s.close()
_, _, payload = buf.partition(b"\r\n\r\n")
man["routes"][name] = summarize(payload)
status, body = http_get(uh, up, path)
except Exception as e:
# Recorded as an error rather than omitted: a manifest missing a
# section must not look like a section that was empty.
# Recorded as an error, never omitted: a missing section must not
# be mistakable for an empty one.
man["routes"][name] = {"error": str(e)}
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w") as f:
continue
bp = os.path.join(raw_dir, name + ".json")
fd = os.open(bp, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(body)
man["routes"][name] = {
"status": status,
"body_len": len(body),
"sha256": hashlib.sha256(body).hexdigest(),
"fields": extract(name, body),
}
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "w") as f:
json.dump(man, f, indent=2, sort_keys=True)
f.write("\n")
print("wrote %s" % args.out)
print("wrote %s" % out_path)
for k, v in man["routes"].items():
print(" %s: %s" % (k, v))
print(" %-13s %s" % (k, v.get("fields", v)))
return 0
INTERESTING = ("coins", "unopenedPacks", "personaId", "personaName", "clubName",
"level", "experience", "accountFunds", "itemCount", "squadId")
def summarize(payload):
def extract(name, body):
"""Named fields per route. Unknown shapes report what they can rather than
raising -- the hash still covers everything."""
try:
doc = json.loads(payload)
d = json.loads(body)
except Exception:
return {"raw_len": len(payload)}
out = {}
def walk(o, prefix=""):
if isinstance(o, dict):
for k, v in o.items():
if k in INTERESTING and not isinstance(v, (dict, list)):
out[k] = v
else:
walk(v, prefix + k + ".")
elif isinstance(o, list):
out.setdefault(prefix.rstrip(".") + ".count", len(o))
walk(doc)
return out
return {"unparsed_len": len(body)}
f = {}
if name == "credits":
f["credits"] = d.get("credits")
elif name == "userMassInfo":
ui = d.get("userInfo", {})
for k in ("personaId", "clubName", "clubAbbr", "trophies"):
if k in ui:
f[k] = ui[k]
for k, v in d.items():
if isinstance(v, list):
f[k + ".count"] = len(v)
elif name == "unassigned":
f["itemData.count"] = len(d.get("itemData", []))
elif name == "activeSquad":
f["id"] = d.get("id")
f["formation"] = d.get("formation")
players = d.get("players", [])
f["players.count"] = len(players)
# Slot -> item id, so a two-player swap is visible in the diff.
f["slots"] = {
str(p.get("index", i)): (p.get("itemData") or {}).get("id")
for i, p in enumerate(players)
}
elif name == "tradePile":
f["auctionInfo.count"] = len(d.get("auctionInfo", []))
elif name == "accountInfo":
f["keys"] = sorted(d.keys()) if isinstance(d, dict) else None
return f
def main():
+12 -2
View File
@@ -50,7 +50,7 @@ class H(http.server.BaseHTTPRequestHandler):
return self.rfile.read(n) if n else b""
def do_GET(self):
if self.path == "/chunked":
if self.path.split("?")[0] == "/chunked":
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Transfer-Encoding", "chunked")
@@ -126,7 +126,7 @@ def main():
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 HTTP/1.1\r\nHost: t\r\n\r\n",
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",
]
@@ -199,6 +199,16 @@ def main():
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()