Syllabus Lesson 109 of 156 · Apps and devices
Apps and devices

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:

  1. The query string: the part of the URL after ?, as name=value pairs joined by &.
  2. Headers: extra labels on the request, such as X-Device-Id: D-7F3A9C.
  3. The body: the main data of a POST request.

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.
  • urlsplit and parse_qsl split 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.
Your turn

Write two functions.

  1. 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 through normalise_name, and each value becomes a string with str(). Write this yourself; do not use the practice world's fields_of.
  2. audit(entries, app_site) returns a dict with three keys, each a sorted list with no repeats:
    • "sdk_hosts": every host (from urlsplit(url).hostname) whose site_of(host) is not app_site;
    • "identifiers": every field name in IDENTIFIER_NAMES that appears with a real value (not ZERO_AD_ID and not empty);
    • "location": every field name in LOCATION_NAMES that appears.
Finally, run report = audit(fitness_app_log(), "fitfox.example") and print it.

Spotted a problem in this lesson? Report it

Code · runs in your browser
Output