mirror of
https://github.com/pluja/awesome-privacy.git
synced 2026-07-21 10:55:39 +02:00
174 lines
6.4 KiB
Python
174 lines
6.4 KiB
Python
#!/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()
|