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:
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"
|
||||
}
|
||||
Executable
+75
@@ -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
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user