diff --git a/.github/workflows/privacy-report.yml b/.github/workflows/privacy-report.yml new file mode 100644 index 0000000..6395697 --- /dev/null +++ b/.github/workflows/privacy-report.yml @@ -0,0 +1,50 @@ +name: Android tracker scan + +# Monthly Android tracker scan via Exodus Privacy, separate from the health +# report. Looks up package ids from F-Droid / Play / IzzyOnDroid links and reports +# known trackers found in each APK. Runs only when the EXODUS_API_TOKEN secret is +# set; without it the scan is skipped and no issue is opened. Request a key from +# api@exodus-privacy.eu.org. Repo health and open-source checks are in the health +# report. + +on: + schedule: + - cron: '0 6 15 * *' # 06:00 UTC on the 15th, offset from the health report + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + tracker-scan: + name: Scan Android apps for trackers + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Run tracker scan + id: scan + env: + EXODUS_API_TOKEN: ${{ secrets.EXODUS_API_TOKEN }} # optional; enables the scan + EXODUS_API_USER: awesome-privacy + run: python3 scripts/privacy_scan.py --out tracker-scan.md + + - name: Ensure label exists + if: steps.scan.outputs.has_findings == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create privacy-audit \ + --color D4C5F9 \ + --description "Automated privacy audit findings" \ + --force || true + + - name: Open tracker scan issue + if: steps.scan.outputs.has_findings == 'true' + uses: peter-evans/create-issue-from-file@v6 + with: + title: "🕵️ Android tracker scan — ${{ steps.scan.outputs.date }}" + content-filepath: ./tracker-scan.md + labels: privacy-audit diff --git a/scripts/privacy_scan.py b/scripts/privacy_scan.py new file mode 100644 index 0000000..b1e042d --- /dev/null +++ b/scripts/privacy_scan.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Android tracker scan for the awesome-privacy list (Exodus Privacy). + +Package ids found in F-Droid, Play, or IzzyOnDroid links are looked up in the +Exodus Privacy database, which scans APKs for known trackers. Exodus flags known +trackers only, so a result is a signal for review, not proof either way. + +The scan needs an Exodus API key. Without EXODUS_API_TOKEN it is skipped with a +note, so the workflow is safe to run before a key is obtained. + +Repository health and open-source verification live in scripts/health_report.py. + +Usage: + python3 scripts/privacy_scan.py [--readme README.md] [--limit N] [--out FILE] + +Environment: + EXODUS_API_TOKEN enables the scan (request a key from api@exodus-privacy.eu.org) + EXODUS_API_USER user-agent handle for Exodus (default: awesome-privacy) + +Standard library only. +""" +import argparse +import datetime +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request + +# Android package ids embedded in store / index links. +HANDLE_RES = [ + re.compile(r"f-droid\.org/(?:[a-z]{2}/)?packages/([A-Za-z0-9._]+)"), + re.compile(r"play\.google\.com/store/apps/details\?id=([A-Za-z0-9._]+)"), + re.compile(r"apt\.izzysoft\.de/fdroid/index/apk/([A-Za-z0-9._]+)"), +] + +EXODUS = "https://reports.exodus-privacy.eu.org" +# Exodus allows 30 req/s globally; stay far under it and never hammer on errors. +EXODUS_DELAY = 0.3 + + +def get_json(url, headers=None, timeout=25): + req = urllib.request.Request(url) + req.add_header("Accept", "application/json") + for k, v in (headers or {}).items(): + req.add_header(k, v) + if not (headers and "User-Agent" in headers): + req.add_header("User-Agent", "awesome-privacy-scan") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.load(resp), None + except urllib.error.HTTPError as exc: + return None, exc.code + except Exception: + return None, "error" + + +def extract_handles(readme): + handles = set() + for rex in HANDLE_RES: + handles.update(rex.findall(readme)) + return sorted(handles) + + +def exodus_headers(): + token = os.environ.get("EXODUS_API_TOKEN") + if not token: + return None + user = os.environ.get("EXODUS_API_USER", "awesome-privacy") + return {"Authorization": f"Token {token}", "User-Agent": f"{user}/1.0"} + + +def scan_trackers(handles, limit=0): + """Return (results, checked, status). status: None | no-token | token-rejected | rate-limited.""" + headers = exodus_headers() + if headers is None: + return None, 0, "no-token" + # Tracker dictionary: one call per run (Exodus caps this endpoint at 3/min). + names, terr = get_json(f"{EXODUS}/api/trackers", headers) + if terr == 401: + return [], 0, "token-rejected" + tdict = (names or {}).get("trackers", {}) if names else {} + results, checked, status = [], 0, None + for handle in handles: + if limit and checked >= limit: + break + checked += 1 + data, err = get_json(f"{EXODUS}/api/search/{handle}", headers) + if err in (429, 503): + status, checked = "rate-limited", checked - 1 + break + if err == 401: + status, checked = "token-rejected", checked - 1 + break + if err or not data: + results.append((handle, None, "no report")) + time.sleep(EXODUS_DELAY) + continue + entry = data.get(handle) or next(iter(data.values()), {}) + reports = entry.get("reports") or [] + if not reports: + results.append((handle, None, "no report")) + time.sleep(EXODUS_DELAY) + continue + latest = max(reports, key=lambda r: r.get("updated_at", "")) + ids = latest.get("trackers") or [] + tnames = [tdict.get(str(i), {}).get("name", f"#{i}") for i in ids] + results.append((handle, len(ids), ", ".join(sorted(tnames)) or "none")) + time.sleep(EXODUS_DELAY) + return results, checked, status + + +def build_report(today, handles, trackers, tchecked, tstatus): + L = [f"# Android tracker scan {today}", "", + "Known trackers found in each app's APK by Exodus Privacy. A signal for", + "review, not an automatic verdict.", "", "## Trackers", ""] + if tstatus == "no-token": + L.append(f"Skipped: no `EXODUS_API_TOKEN` set. {len(handles)} Android package id(s) " + "were found and will be scanned once a key is added " + "(request one at api@exodus-privacy.eu.org).") + elif tstatus == "token-rejected": + L.append("Skipped: Exodus rejected the API token (401). Check the `EXODUS_API_TOKEN` secret.") + else: + withtr = [t for t in (trackers or []) if t[1]] + L.append(f"{len(withtr)} app(s) with trackers, {tchecked} scanned.") + L.append("") + if trackers: + for handle, n, detail in sorted(trackers, key=lambda t: -(t[1] or 0)): + L.append(f"- `{handle}` - {n} tracker(s): {detail}" if n + else f"- `{handle}` - {detail}") + else: + L.append("No Android packages found in the list.") + if tstatus == "rate-limited": + L.append("") + L.append("_Stopped early: Exodus rate limit hit. Remaining apps not scanned this run._") + L.append("") + return "\n".join(L) + "\n" + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--readme", default="README.md") + ap.add_argument("--limit", type=int, default=0, help="cap apps scanned (0 = all)") + ap.add_argument("--out", default=None) + args = ap.parse_args() + + with open(args.readme, encoding="utf-8") as fh: + readme = fh.read() + handles = extract_handles(readme) + print(f"[trackers] {len(handles)} android package id(s)", file=sys.stderr) + + trackers, tchecked, tstatus = scan_trackers(handles, args.limit) + today = datetime.date.today().isoformat() + report = build_report(today, handles, trackers, tchecked, tstatus) + if args.out: + with open(args.out, "w", encoding="utf-8") as fh: + fh.write(report) + print(f"[trackers] wrote {args.out}", file=sys.stderr) + else: + sys.stdout.write(report) + + has_findings = bool(trackers and any(t[1] for t in trackers)) + gh_out = os.environ.get("GITHUB_OUTPUT") + if gh_out: + with open(gh_out, "a") as fh: + fh.write(f"has_findings={'true' if has_findings else 'false'}\n") + fh.write(f"date={today}\n") + + +if __name__ == "__main__": + main()