Audit a practice app
Why it matters A traffic capture is a long, messy list. The skill that turns it into something useful is simple: pull out every field, then sort them into "who received it" and "what it was". You will write that audit for a practice fitness app, and the same idea works on any request log you are allowed to read.
The practice capture
Our practice app is FitFox, a made-up step counter whose own site is fitfox.example. Its capture is a Python list. Each item is one request, as an interception proxy would log it:
from watched.leg5b_apps import fitness_app_log
log = fitness_app_log()
print(len(log)) # 12
print(log[3])
# {'t': 3, 'device': 'phone', 'method': 'GET',
# 'url': 'https://sdk.adco.example/ad?idfa=00000000-0000-0000-0000-000000000000&slot=banner&lat=53.80&lon=-1.55',
# 'headers': {}, 'body': {}}
t is seconds since the app opened. headers is a dict of request headers. body is the data the app sent, already decoded into a dict (it is empty for GET requests, which carry their data in the address).
SDK (software development kit): code from another company built into an app, for ads, analytics, maps or crash reports. In a capture, an SDK shows up as requests to that company's domains.
Three places data hides
An app can put a piece of data in three places, like writing on the envelope, on a sticky note on the envelope, or in the letter:
- The query string: the part of the URL after
?, asname=valuepairs joined by&. - Headers: extra labels on the request, such as
X-Device-Id: D-7F3A9C. - The body: the main data of a
POSTrequest.
Python's standard library splits a query string for you. urlsplit breaks a URL into parts, and parse_qsl turns the query into a list of (name, value) pairs:
from urllib.parse import urlsplit, parse_qsl
parts = urlsplit("https://tiles.mapkit.example/v1/tile?lat=53.80&zoom=14")
print(parts.hostname) # tiles.mapkit.example
print(parts.query) # lat=53.80&zoom=14
print(parse_qsl(parts.query)) # [('lat', '53.80'), ('zoom', '14')]
Making names comparable
The same idea can be spelled device_id in a body and X-Device-Id in a header (the X- is an old habit for "custom header"). Before comparing, normalise every name: lower case, turn - into _, and drop a leading x_. The practice world gives you this as normalise_name:
from watched.leg5b_apps import normalise_name
print(normalise_name("X-Device-Id")) # device_id
Values come in different types (the body has numbers like 8412). Turn every value into a string with str(value), so they all compare the same way.
What counts as what
The practice world also gives you two sets of normalised names. IDENTIFIER_NAMES holds names that carry an ID for you or your phone (device_id, user_id, idfa, email_sha256 and so on). LOCATION_NAMES holds names that say where you are (lat, lon, city, postcode and so on).
Zeroed ad ID: when you tell an iPhone app not to track you, or delete your Android advertising ID, apps that ask for the ID get 00000000-0000-0000-0000-000000000000. That value identifies nobody, so it does not count as an identifier. It is in ZERO_AD_ID. The next lesson explains why it appears.
First party or not
A request is to an SDK when its host is on a different site from the app's own. site_of from watched gives the site: site_of("cdn.fitfox.example") is "fitfox.example", and site_of("sdk.adco.example") is "adco.example".
A common misconception
"Only requests to strange domains matter." FitFox sends your heart rate and exact position to its own server, and a hashed email to partners.fitfox.example. That last one looks first party, but the name alone cannot tell you who runs it. Your audit will not flag it; the next exercise will.
In other languages
JavaScript: URL and URLSearchParams split a URL.
function allFields(entry) {
const norm = n => n.toLowerCase().replaceAll("-", "_").replace(/^x_/, "");
const out = [];
for (const [k, v] of new URL(entry.url).searchParams) out.push([norm(k), v]);
for (const [k, v] of Object.entries(entry.headers)) out.push([norm(k), String(v)]);
for (const [k, v] of Object.entries(entry.body)) out.push([norm(k), String(v)]);
return out;
}
Go: net/url does the same job.
u, _ := url.Parse(rawURL)
for name, values := range u.Query() {
for _, v := range values { fmt.Println(name, v) }
}
fmt.Println(u.Hostname())
Recap
- Data in a request hides in three places: the query string, the headers and the body.
urlsplitandparse_qslsplit a URL; normalising names lets you compare them.- A zeroed ad ID identifies nobody, so it is not counted.
- Comparing sites finds most SDKs, but a first-party-looking name can still be run by someone else.
Write two functions.
all_fields(entry)returns a list of(name, value)tuples for one log entry: first every pair in the URL's query string (in order), then every header, then every body item. Each name goes throughnormalise_name, and each value becomes a string withstr(). Write this yourself; do not use the practice world'sfields_of.audit(entries, app_site)returns a dict with three keys, each a sorted list with no repeats:"sdk_hosts": every host (fromurlsplit(url).hostname) whosesite_of(host)is notapp_site;"identifiers": every field name inIDENTIFIER_NAMESthat appears with a real value (notZERO_AD_IDand not empty);"location": every field name inLOCATION_NAMESthat appears.
report = audit(fitness_app_log(), "fitfox.example") and print it.
This lesson is locked
Lessons open one at a time. Finish the previous lesson to unlock this one.