Compare commits

...
11 Commits
Author SHA1 Message Date
pluja 775c5af85f ci: run open-source verification on the repos a PR adds 2026-07-08 11:03:19 +03:00
pluja d78fc0d48d ci: add Android tracker scan via Exodus Privacy (token-gated) 2026-07-08 10:08:41 +03:00
pluja 17f308cec6 ci: fold open-source verification into the health report 2026-07-08 10:08:41 +03:00
plujaandGitHub e2a77ce01b Merge pull request #582 from brentspine/patch-3
Added Proton Authenticator
2026-07-07 17:10:50 +03:00
plujaandGitHub 854240fce4 Merge branch 'main' into patch-3 2026-07-07 17:10:39 +03:00
plujaandGitHub 4a5322b266 Merge pull request #580 from brentspine/patch-1
Removed unnecessary line (by AI)
2026-07-07 17:09:50 +03:00
plujaandGitHub b71a4077d4 Merge pull request #907 from 0xDE57/patch-3
Update SkyDroid URL, Mark unmaintained
2026-07-06 13:09:58 +03:00
pluja 6b69587a3e ci: drop removed lychee --exclude-mail flag and pin lychee to v0.23.0 2026-07-06 12:54:00 +03:00
arbitrary hexcodeandGitHub 80e660ee1f Update SkyDroid URL, Mark unmaintained
- https://skydroid.app/ is dead. re-point URL to github repo: https://github.com/redsolver/skydroid
- Mark as unmaintained. The repo is archived. Project is dead.
2026-07-06 08:39:19 +00:00
LucaandGitHub 748e7415b2 Added Proton Authenticator (https://github.com/pluja/awesome-privacy/issues/554) 2025-11-21 13:22:08 +01:00
LucaandGitHub 6802c278b4 Removed unnecessary line (by AI) 2025-11-21 13:13:31 +01:00
6 changed files with 581 additions and 174 deletions
+8 -169
View File
@@ -1,8 +1,9 @@
name: Health report
# Monthly maintenance scan of README.md. Finds dead links and repos that look
# abandoned (archived, or no push in over 18 months), then opens one issue with
# the findings. Opens nothing when everything is healthy.
# Monthly maintenance + open-source audit of README.md. Finds dead links and
# repos that are gone, archived, stale (no push in 18+ months), unlicensed, or
# have no real source code. Opens one issue with the findings, nothing when clean.
# Logic lives in scripts/health_report.py so it can be run and tested locally.
on:
schedule:
@@ -24,7 +25,8 @@ jobs:
- name: Run lychee on README.md
uses: lycheeverse/lychee-action@v2
with:
# Whole file, JSON output for the parser below.
lycheeVersion: v0.23.0
# Whole file, JSON output for the parser in health_report.py.
# fail:false keeps the job green so we can build the report either way.
args: >-
--no-progress
@@ -33,7 +35,6 @@ jobs:
--max-retries 3
--retry-wait-time 2
--timeout 20
--exclude-mail
--accept 200..=299,403,429,999
README.md
format: json
@@ -42,173 +43,11 @@ jobs:
fail: false
failIfEmpty: false
- name: Build report (dead links + stale repos)
- name: Build report (dead links + repository issues)
id: scan
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 - <<'PY'
import datetime as dt
import json, os, re, urllib.error, urllib.request
NOW = dt.datetime.now(dt.timezone.utc)
STALE_DAYS = 18 * 30 # ~18 months
TOKEN = os.environ.get("GITHUB_TOKEN", "")
# ---- 1. Dead links, from the lychee JSON report ------------------------
dead = []
dead_ok = True
try:
with open("lychee/out.json") as fh:
data = json.load(fh)
emap = data.get("error_map") or data.get("fail_map") or {}
for entries in emap.values():
for item in entries:
url = item.get("url", "")
status = item.get("status") or {}
text = status.get("text") or status.get("details") or "error"
dead.append((url, text))
except FileNotFoundError:
dead_ok = False
print("::warning::lychee/out.json not found; skipping dead-link section")
except Exception as exc:
dead_ok = False
print(f"::warning::Could not parse lychee output ({exc})")
# De-duplicate while keeping order.
seen = set()
dead = [d for d in dead if not (d[0] in seen or seen.add(d[0]))]
# ---- 2. Stale / archived repositories ---------------------------------
with open("README.md", encoding="utf-8") as fh:
readme = fh.read()
# Capture host/owner/repo; any subpath (/tree/..., /blob/...) is ignored.
repo_re = re.compile(
r"https?://(?:www\.)?(github\.com|codeberg\.org)/"
r"([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)"
)
# First path segments that are never a real owner.
RESERVED = {
"sponsors", "orgs", "topics", "about", "features", "marketplace",
"settings", "explore", "collections", "apps", "notifications",
"login", "join", "pricing", "security", "site", "contact",
"readme", "search", "new", "watching", "stars",
}
repos = {} # (host, owner_lower, repo_lower) -> (host, owner, repo)
for host, owner, repo in repo_re.findall(readme):
if owner.lower() in RESERVED:
continue
repo = repo.rstrip(".")
if repo.endswith(".git"):
repo = repo[:-4]
if not repo:
continue
key = (host, owner.lower(), repo.lower())
repos.setdefault(key, (host, owner, repo))
def fetch(url, token=None):
req = urllib.request.Request(url)
req.add_header("Accept", "application/json")
req.add_header("User-Agent", "awesome-privacy-health-report")
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=20) as resp:
return json.load(resp)
def parse_ts(value):
if not value:
return None
try:
return dt.datetime.strptime(
value.replace("Z", "+0000"), "%Y-%m-%dT%H:%M:%S%z"
)
except ValueError:
return None
stale = [] # (slug, reason)
for host, owner, repo in sorted(repos.values()):
slug = f"{host}/{owner}/{repo}"
try:
if host == "github.com":
info = fetch(
f"https://api.github.com/repos/{owner}/{repo}", TOKEN
)
last = parse_ts(info.get("pushed_at"))
else: # codeberg.org (Forgejo/Gitea API, public, no token)
info = fetch(
f"https://codeberg.org/api/v1/repos/{owner}/{repo}"
)
last = parse_ts(info.get("updated_at"))
except urllib.error.HTTPError as exc:
if exc.code in (404, 410):
stale.append((slug, f"repository not found (HTTP {exc.code})"))
else:
print(f"::warning::{slug}: HTTP {exc.code}, skipped")
continue
except Exception as exc:
print(f"::warning::{slug}: {exc}, skipped")
continue
reasons = []
if info.get("archived"):
reasons.append("archived")
if last is not None:
age = (NOW - last).days
if age > STALE_DAYS:
months = age // 30
reasons.append(
f"no push since {last.date()} (~{months} months)"
)
if reasons:
stale.append((slug, "; ".join(reasons)))
# ---- 3. Assemble the report -------------------------------------------
today = NOW.date().isoformat()
lines = [f"# Health report {today}", ""]
lines.append("Automated monthly scan of `README.md`.")
lines.append("")
lines.append(f"## Dead links ({len(dead)})")
lines.append("")
if dead:
for url, text in dead:
lines.append(f"- {url} - {text}")
elif dead_ok:
lines.append("None found.")
else:
lines.append("_Link scan produced no output this run; dead links were not checked._")
lines.append("")
lines.append(f"## Stale or archived repositories ({len(stale)})")
lines.append("")
lines.append(
"Archived, or no push in over 18 months. "
"Consider marking with the skull icon or delisting."
)
if stale:
lines.append("")
for slug, reason in stale:
lines.append(f"- {slug} - {reason}")
else:
lines.append("")
lines.append("None found.")
lines.append("")
lines.append(
"_Some entries may be transient. Verify before acting._"
)
with open("health-report.md", "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
has_findings = bool(dead or stale or not dead_ok)
out = os.environ.get("GITHUB_OUTPUT")
if out:
with open(out, "a") as fh:
fh.write(f"has_findings={'true' if has_findings else 'false'}\n")
fh.write(f"date={today}\n")
print(f"Dead links: {len(dead)} | Stale repos: {len(stale)}")
PY
run: python3 scripts/health_report.py --lychee lychee/out.json --out health-report.md
- name: Ensure maintenance label exists
if: steps.scan.outputs.has_findings == 'true'
+23 -4
View File
@@ -1,8 +1,10 @@
name: PR Lint (links + format)
# Runs on pull requests that touch the list. Two independent checks:
# 1. link-check - lychee looks for dead links in README.md.
# 2. format-lint - warns when a newly added entry has no description.
# Runs on pull requests that touch the list. Three independent checks:
# 1. link-check - lychee looks for dead links in README.md.
# 2. format-lint - warns when a newly added entry has no description.
# 3. openness-check - verifies the repos a PR ADDS: gone, archived, unlicensed,
# or no real source code. Warns, never blocks.
on:
pull_request:
@@ -23,6 +25,7 @@ jobs:
- name: Run lychee on README.md
uses: lycheeverse/lychee-action@v2
with:
lycheeVersion: v0.23.0
# Scope strictly to README.md.
# --accept whitelists transient/bot-block codes so the PR only hard
# fails on real dead links (404/410) and dead domains (DNS/connection
@@ -36,7 +39,6 @@ jobs:
--max-retries 3
--retry-wait-time 2
--timeout 20
--exclude-mail
--accept 100..=103,200..=299,403,429,500,502,503,504,999
README.md
format: markdown
@@ -122,3 +124,20 @@ jobs:
# Warn only. Format nits never block a PR.
sys.exit(0)
PY
openness-check:
name: Check added repositories
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0 # need the base commit to diff against
- name: Verify open-source claims of added repos
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # full rate limit
BASE_SHA: ${{ github.event.pull_request.base.sha }}
# Checks only repos this PR adds. Posts warnings to the job summary and
# inline annotations; never fails the PR (the maintainer decides).
run: python3 scripts/health_report.py --diff-base "$BASE_SHA"
+50
View File
@@ -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
+2 -1
View File
@@ -134,6 +134,7 @@
- [Owky](https://github.com/charlietango/owky) [💀](#icons) - Free and Open Source Two-Factor Authenticator for IOS users.
- [🤖](#icons) [FreeOTPPlus](https://github.com/helloworld1/FreeOTPPlus) - Enhanced fork of FreeOTP-Android providing a feature-rich 2FA authenticator.
- [🤖](#icons) [Authenticator Pro](https://github.com/jamie-mh/AuthenticatorPro) - Two-Factor Authentication (2FA) client for Android + Wear OS.
- [Proton Authenticator](https://proton.me/authenticator) - Proton Authenticator is an [open source](https://proton.me/community/open-source#apps) and [end-to-end encrypted](https://proton.me/blog/password-encryption) 2FA app thats simple and free to use.
- [2FAS Auth](https://2fas.com/auth) - Open source TOTP authenticator for iOS and Android with a companion browser extension and no account required. GPL-3.0 licensed.
[Back to top 🔝](#contents)
@@ -170,7 +171,7 @@
- [Aurora Droid](https://auroraoss.com/download/#aurora-droid) - Aurora Droid is a modern FOSS client for F-Droid.
- [Foxy Droid](https://github.com/kitsunyan/foxy-droid) [💀](#icons) - Unofficial F-Droid client in the style of the classic one.
- [FossDroid](https://fossdroid.com/) - Fossdroid's aim is to promote free and open source apps on the Android platform: newest, trendiest and the most popular ones.
- [SkyDroid](https://skydroid.app/) - Decentralized App Store for Android
- [SkyDroid](https://github.com/redsolver/skydroid) [💀](#icons) - Decentralized App Store for Android
- [Obtainium](https://github.com/ImranR98/Obtainium) - Get app updates directly from the source.
- [Accrescent](https://github.com/accrescent/accrescent) - A novel Android app store focused on security, privacy, and usability.
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""Monthly health + open-source audit of README.md.
One pass over every repository linked in the list, reporting:
- Dead links, parsed from a lychee JSON report (produced by the workflow step).
- Repository issues: gone, archived, stale (no push in 18+ months), unlicensed,
or no real source code (only binaries, images, or documentation, the
"blob posing as open source" case).
Repos already marked unmaintained (💀) in the list are shown in a separate
section and do not, on their own, trigger a new issue. Every finding is a signal
for human review, not an automatic verdict.
Usage:
python3 scripts/health_report.py [--readme README.md]
[--lychee lychee/out.json] [--limit N] [--out FILE]
Environment:
GITHUB_TOKEN / GH_TOKEN raises the GitHub rate limit from 60 to 5000 req/hr
Standard library only.
"""
import argparse
import datetime as dt
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
STALE_DAYS = 18 * 30 # ~18 months
REPO_RE = re.compile(
r"https?://(?:www\.)?(github\.com|codeberg\.org)/"
r"([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)"
)
RESERVED = {
"sponsors", "orgs", "topics", "about", "features", "marketplace", "settings",
"explore", "collections", "apps", "notifications", "login", "join", "pricing",
"security", "site", "contact", "readme", "search", "new", "watching", "stars",
}
# The list's own repo (badges, mirror, edit and issue links) is not a listed tool.
SELF_REPOS = {
("github.com", "pluja", "awesome-privacy"),
("codeberg.org", "pluja", "awesome-privacy"),
}
# Linguist "languages" that are documentation or markup, not real source code.
DOC_ONLY = {
"Markdown", "Text", "reStructuredText", "AsciiDoc", "Org", "TeX",
"Roff", "Rich Text Format",
}
def get_json(url, token=None, timeout=20):
req = urllib.request.Request(url)
req.add_header("Accept", "application/json")
req.add_header("User-Agent", "awesome-privacy-health-report")
if token:
req.add_header("Authorization", f"Bearer {token}")
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 parse_ts(value):
if not value:
return None
try:
return dt.datetime.strptime(value.replace("Z", "+0000"), "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
return None
def _clean(host, owner, repo):
if owner.lower() in RESERVED:
return None
repo = repo[:-4] if repo.rstrip(".").endswith(".git") else repo.rstrip(".")
if not repo:
return None
key = (host, owner.lower(), repo.lower())
return None if key in SELF_REPOS else (key, (host, owner, repo))
def extract_repos(readme):
repos = {}
for host, owner, repo in REPO_RE.findall(readme):
cleaned = _clean(host, owner, repo)
if cleaned:
key, value = cleaned
repos.setdefault(key, value)
return sorted(repos.values())
def extract_skull_repos(readme):
skull = set()
for line in readme.splitlines():
if "💀" not in line:
continue
for host, owner, repo in REPO_RE.findall(line):
cleaned = _clean(host, owner, repo)
if cleaned:
skull.add(cleaned[0])
return skull
def source_concern(langs):
"""Flag a repo whose 'source' is only binaries, images, or documentation."""
if langs is None:
return []
if not langs:
return ["no source code detected (only binaries, images, or empty)"]
if not [l for l in langs if l not in DOC_ONLY]:
return ["only documentation or markup (" + ", ".join(sorted(langs)) + ")"]
return []
def check_repo(host, owner, repo, now, token):
"""Combined staleness + openness reasons for one repo (empty = healthy)."""
if host == "github.com":
info, err = get_json(f"https://api.github.com/repos/{owner}/{repo}", token)
else:
info, err = get_json(f"https://codeberg.org/api/v1/repos/{owner}/{repo}")
if err == 403:
return ["__ratelimit__"]
if err in (404, 410):
return ["repository not found"]
if err or info is None:
return [] # transient; do not flag on a network blip
reasons = []
if info.get("archived"):
reasons.append("archived")
last = parse_ts(info.get("pushed_at") if host == "github.com" else info.get("updated_at"))
if last is not None and (now - last).days > STALE_DAYS:
reasons.append(f"no push since {last.date()} (~{(now - last).days // 30} months)")
if host == "github.com" and not info.get("license"):
reasons.append("no license") # Codeberg/Gitea does not expose this reliably
if host == "github.com":
langs, lerr = get_json(f"https://api.github.com/repos/{owner}/{repo}/languages", token)
if lerr == 403:
return ["__ratelimit__"]
else:
langs, _ = get_json(f"https://codeberg.org/api/v1/repos/{owner}/{repo}/languages")
reasons += source_concern(langs)
return reasons
def scan_repos(repos, skull_set, now, token, limit=0):
findings, checked, rate_limited = [], 0, False
for host, owner, repo in repos:
if limit and checked >= limit:
break
checked += 1
reasons = check_repo(host, owner, repo, now, token)
if reasons == ["__ratelimit__"]:
rate_limited, checked = True, checked - 1
break
if reasons:
key = (host, owner.lower(), repo.lower())
findings.append((f"{host}/{owner}/{repo}", "; ".join(reasons), key in skull_set))
time.sleep(0.05)
return findings, checked, rate_limited
def added_readme_text(base):
"""The lines a PR adds to README.md, for scoping the check to new entries."""
try:
out = subprocess.run(
["git", "diff", "--unified=0", base, "HEAD", "--", "README.md"],
capture_output=True, text=True, check=False,
).stdout
except Exception as exc:
print(f"::warning::could not compute diff ({exc})", file=sys.stderr)
return ""
return "\n".join(
line[1:] for line in out.splitlines()
if line.startswith("+") and not line.startswith("+++")
)
def run_pr_check(base, now, token, limit):
"""Check only the repos a PR adds. Warns, never blocks. Returns exit code 0."""
added = added_readme_text(base)
repos = extract_repos(added)
findings, checked, rate_limited = scan_repos(
repos, extract_skull_repos(added), now, token, limit
)
new = [(s, w) for s, w, sk in findings if not sk]
for slug, why in new:
print(f"::warning::{slug} - {why}") # inline annotation on the PR
lines = ["## Open-source check of added repositories", "",
f"Checked {checked} newly added repo(s)."]
if new:
lines += ["", "Review these before merge (warnings, not blockers):"]
lines += [f"- {slug} - {why}" for slug, why in new]
else:
lines.append("No open-source concerns in the added repositories.")
if rate_limited:
lines += ["", "_GitHub rate limit hit; some repos were not checked._"]
summary = "\n".join(lines) + "\n"
step = os.environ.get("GITHUB_STEP_SUMMARY")
if step:
with open(step, "a", encoding="utf-8") as fh:
fh.write(summary)
else:
sys.stdout.write(summary)
return 0 # warn only; the maintainer decides
def parse_dead_links(path):
"""Return (dead_links, scan_ran). dead_links = [(url, reason), ...]."""
try:
with open(path) as fh:
data = json.load(fh)
except FileNotFoundError:
return [], False
except Exception as exc:
print(f"::warning::could not parse lychee output ({exc})", file=sys.stderr)
return [], False
dead, seen = [], set()
emap = data.get("error_map") or data.get("fail_map") or {}
for entries in emap.values():
for item in entries:
url = item.get("url", "")
status = item.get("status") or {}
text = status.get("text") or status.get("details") or "error"
if url and url not in seen:
seen.add(url)
dead.append((url, text))
return dead, True
def build_report(today, dead, dead_ok, findings, checked, rate_limited):
new = [(s, w) for s, w, sk in findings if not sk]
known = [(s, w) for s, w, sk in findings if sk]
L = [f"# Health report {today}", "", "Automated monthly scan of `README.md`. Every",
"finding is a signal for human review, not an automatic verdict.", ""]
L.append(f"## Dead links ({len(dead)})")
L.append("")
if dead:
L += [f"- {url} - {text}" for url, text in dead]
elif dead_ok:
L.append("None found.")
else:
L.append("_Link scan produced no output this run; dead links were not checked._")
L.append("")
L.append(f"## Repository issues ({len(new)} new, {len(known)} already 💀, {checked} checked)")
L.append("")
L.append("Repos that are gone, archived, stale (no push in 18+ months), unlicensed,")
L.append("or have no real source code (only binaries, images, or documentation).")
L.append("Review before delisting, marking 💀, or trusting an \"open source\" claim.")
L.append("")
if new:
L += [f"- {slug} - {why}" for slug, why in new]
else:
L.append("No new findings.")
if rate_limited:
L.append("")
L.append("_Stopped early: GitHub rate limit hit. CI runs with a token for the full pass._")
if known:
L += ["", "### Already marked unmaintained (💀)", "",
"These already carry the skull in the list. Shown for completeness.", ""]
L += [f"- {slug} - {why}" for slug, why in known]
L += ["", "_Some entries may be transient. Verify before acting._", ""]
return "\n".join(L) + "\n"
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--readme", default="README.md")
ap.add_argument("--lychee", default="lychee/out.json")
ap.add_argument("--limit", type=int, default=0, help="cap repos checked (0 = all)")
ap.add_argument("--out", default=None)
ap.add_argument("--diff-base", default=None,
help="PR mode: check only repos added since this git ref (warns, never blocks)")
args = ap.parse_args()
now = dt.datetime.now(dt.timezone.utc)
today = now.date().isoformat()
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if args.diff_base:
sys.exit(run_pr_check(args.diff_base, now, token, args.limit))
with open(args.readme, encoding="utf-8") as fh:
readme = fh.read()
repos = extract_repos(readme)
skull_set = extract_skull_repos(readme)
print(f"[health] {len(repos)} repos, {len(skull_set)} already 💀", file=sys.stderr)
dead, dead_ok = parse_dead_links(args.lychee)
findings, checked, rate_limited = scan_repos(repos, skull_set, now, token, args.limit)
report = build_report(today, dead, dead_ok, findings, checked, rate_limited)
if args.out:
with open(args.out, "w", encoding="utf-8") as fh:
fh.write(report)
print(f"[health] wrote {args.out}", file=sys.stderr)
else:
sys.stdout.write(report)
# New (non-skull) repo problems or any dead links open an issue.
new_findings = [f for f in findings if not f[2]]
has_findings = bool(dead) or bool(new_findings) or not dead_ok
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()
+173
View File
@@ -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()