adb-toolkit/backend_privacy.go
jegly 26d82c68d5 Add GSI Loader, APK Audit, Firmware, Intent Lab, Screen Mirror, Magisk, and App Lock features; bump to v1.2.0
- New sidebar views: GSI Loader (DSU + permanent fastboot flash), APK Audit,
  Firmware, Intent Lab, Screen Mirror
- New backend modules: backend_gsi.go, backend_apkaudit*.go, backend_firmware.go,
  backend_intent.go, backend_magisk.go, backend_applock.go, backend_scrcpy.go,
  backend_payload.go, backend_privacy.go, backend_overview.go, backend_bootinfo.go,
  backend_flasher.go, backend_transfer.go, backend_filehttp.go
- App Lock / danger-gate infrastructure (DangerGate, LockGate, applock.ts)
- Privileged uninstall via embedded Android helper dex (android-helper/)
- nfpm packaging version bump to 1.2.0
2026-07-06 15:17:59 +10:00

263 lines
9.2 KiB
Go

package main
// Privacy & Tracker Scanner.
//
// Pulls an installed app's base APK, scans its DEX bytecode for known
// third-party tracker/analytics/ad SDK signatures, cross-references declared
// dangerous permissions, and derives a 0-100 Privacy Score (A-F grade). This
// reuses the APK auditor's DEX tracker matcher (matchTrackers /
// trackerSignatures) and the shared dangerousPermissions set, and additionally
// enriches the shared tracker DB below (which also improves the full auditor).
//
// Data source: a bundled static signature list (Exodus-Privacy-style code
// signatures = Java package prefixes as they appear in classes*.dex). No runtime
// network — works fully offline on any device.
import (
"archive/zip"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// extraTrackerSignatures widens the built-in trackerSignatures set with more
// well-known Exodus-catalogued SDKs. Merged into the shared maps at init so both
// the Privacy Scanner and the APK auditor see them. Signatures are conservative
// package prefixes chosen to avoid false positives.
var extraTrackerSignatures = map[string][]string{
"Google Tag Manager": {"com/google/android/gms/tagmanager"},
"Amazon Mobile Ads": {"com/amazon/device/ads", "com/amazon/aps"},
"AdColony": {"com/adcolony"},
"Startapp": {"com/startapp"},
"Mintegral": {"com/mbridge", "com/mintegral"},
"Pangle (ByteDance)": {"com/bytedance/sdk/openadsdk", "com/bytedance/pangle"},
"ByteDance AppLog": {"com/bytedance/applog"},
"PubMatic": {"com/pubmatic"},
"Criteo": {"com/criteo"},
"Smaato": {"com/smaato"},
"Fyber": {"com/fyber"},
"Taboola": {"com/taboola"},
"Outbrain": {"com/outbrain"},
"CleverTap": {"com/clevertap"},
"MoEngage": {"com/moengage"},
"Airship": {"com/urbanairship"},
"Leanplum": {"com/leanplum"},
"Batch": {"com/batch/android"},
"Iterable": {"com/iterable"},
"Pushwoosh": {"com/pushwoosh"},
"Swrve": {"com/swrve"},
"Optimizely": {"com/optimizely"},
"Adobe Experience": {"com/adobe/marketing/mobile", "com/adobe/mobile"},
"New Relic": {"com/newrelic"},
"Datadog": {"com/datadog/android"},
"Instabug": {"com/instabug"},
"Embrace": {"io/embrace/android"},
"Countly": {"ly/count/android"},
"Matomo": {"org/matomo", "org/piwik"},
"Snowplow": {"com/snowplowanalytics"},
"Smartlook": {"com/smartlook"},
"Nielsen": {"com/nielsen/app"},
"Mapbox Telemetry": {"com/mapbox/android/telemetry"},
"Foursquare": {"com/foursquare"},
"Gimbal": {"com/gimbal"},
"Radar": {"io/radar/sdk"},
}
var extraTrackerCategory = map[string]string{
"Google Tag Manager": "Analytics", "Amazon Mobile Ads": "Advertising", "AdColony": "Advertising",
"Startapp": "Advertising", "Mintegral": "Advertising", "Pangle (ByteDance)": "Advertising",
"ByteDance AppLog": "Analytics", "PubMatic": "Advertising", "Criteo": "Advertising",
"Smaato": "Advertising", "Fyber": "Advertising", "Taboola": "Advertising", "Outbrain": "Advertising",
"CleverTap": "Analytics", "MoEngage": "Marketing", "Airship": "Marketing", "Leanplum": "Marketing",
"Batch": "Marketing", "Iterable": "Marketing", "Pushwoosh": "Push/Analytics", "Swrve": "Marketing",
"Optimizely": "Analytics", "Adobe Experience": "Analytics", "New Relic": "Analytics",
"Datadog": "Analytics", "Instabug": "Crash reporting", "Embrace": "Crash reporting",
"Countly": "Analytics", "Matomo": "Analytics", "Snowplow": "Analytics", "Smartlook": "Analytics",
"Nielsen": "Analytics", "Mapbox Telemetry": "Location", "Foursquare": "Location",
"Gimbal": "Location", "Radar": "Location",
}
func init() {
for name, sigs := range extraTrackerSignatures {
if _, exists := trackerSignatures[name]; !exists {
trackerSignatures[name] = sigs
}
}
for name, cat := range extraTrackerCategory {
if _, exists := trackerCategory[name]; !exists {
trackerCategory[name] = cat
}
}
}
// privacyCategoryWeight is the score penalty per unique tracker of a category.
// Advertising / attribution / location are weighted heaviest (most invasive);
// crash reporting is light (usually operational, not surveillance).
var privacyCategoryWeight = map[string]int{
"Advertising": 12,
"Location": 12,
"Attribution": 8,
"Marketing": 8,
"Analytics": 7,
"Push/Analytics": 6,
"Crash reporting": 3,
}
const defaultTrackerWeight = 6
type PrivacyTracker struct {
Name string `json:"name"`
Category string `json:"category"`
Matches int `json:"matches"`
}
type PrivacyReport struct {
PackageName string `json:"packageName"`
Score int `json:"score"` // 0-100, higher = more private
Grade string `json:"grade"` // A-F
TrackerCount int `json:"trackerCount"`
Trackers []PrivacyTracker `json:"trackers"`
DangerousPermissions []string `json:"dangerousPermissions"`
ApkSize int64 `json:"apkSize"`
}
// ScanAppPrivacy pulls the base APK of an installed package, scans it for
// tracker SDKs, collects its declared dangerous permissions, and scores it.
func (a *App) ScanAppPrivacy(packageName string) (PrivacyReport, error) {
if err := validatePackageName(packageName); err != nil {
return PrivacyReport{}, err
}
report := PrivacyReport{PackageName: packageName}
// Locate the base APK on the device.
out, err := a.runAdbShell("pm", "path", packageName)
if err != nil {
return report, fmt.Errorf("could not locate package on device: %w", err)
}
var remote string
for _, line := range strings.Split(out, "\n") {
p := strings.TrimPrefix(strings.TrimSpace(line), "package:")
if strings.HasSuffix(p, "base.apk") {
remote = p
break
}
if remote == "" && strings.HasSuffix(p, ".apk") {
remote = p
}
}
if remote == "" {
return report, fmt.Errorf("no APK path found for %s", packageName)
}
// Pull to a temp file; unlike the auditor we don't need to keep it around.
tmp := filepath.Join(os.TempDir(), "atk-privacy-"+sanitizeFileToken(packageName)+".apk")
if _, err := a.runCommandTimeout(auditCommandTimeout, "adb", "pull", remote, tmp); err != nil {
return report, fmt.Errorf("failed to pull APK: %w", err)
}
defer os.Remove(tmp)
if info, statErr := os.Stat(tmp); statErr == nil {
report.ApkSize = info.Size()
}
// Scan DEX bytecode for tracker signatures.
trackerHits := map[string]int{}
if zr, zerr := zip.OpenReader(tmp); zerr == nil {
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, "classes") || !strings.HasSuffix(f.Name, ".dex") {
continue
}
if f.UncompressedSize64 > maxDexBytes {
continue
}
if data := readZipEntry(f); data != nil {
matchTrackers(data, trackerHits)
}
}
zr.Close()
} else {
return report, fmt.Errorf("could not open pulled APK: %w", zerr)
}
for name, n := range trackerHits {
report.Trackers = append(report.Trackers, PrivacyTracker{
Name: name, Category: trackerCategory[name], Matches: n,
})
}
// Heaviest categories first, then alphabetical.
sort.Slice(report.Trackers, func(i, j int) bool {
wi, wj := privacyCategoryWeight[report.Trackers[i].Category], privacyCategoryWeight[report.Trackers[j].Category]
if wi != wj {
return wi > wj
}
return report.Trackers[i].Name < report.Trackers[j].Name
})
report.TrackerCount = len(report.Trackers)
// Declared dangerous permissions (a permission named anywhere in the package
// dump is declared/involved for this package).
report.DangerousPermissions = a.declaredDangerousPermissions(packageName)
report.Score, report.Grade = computePrivacyScore(report.Trackers, report.DangerousPermissions)
return report, nil
}
// declaredDangerousPermissions returns the dangerous permissions the package
// declares, read from its dumpsys output.
func (a *App) declaredDangerousPermissions(packageName string) []string {
dump, err := a.runAdbShellTimeout(30*1e9, "dumpsys", "package", packageName)
if err != nil || dump == "" {
return nil
}
var found []string
for perm := range dangerousPermissions {
if strings.Contains(dump, perm) {
found = append(found, perm)
}
}
sort.Strings(found)
return found
}
// computePrivacyScore derives a 0-100 score (higher = more private) and an A-F
// grade from the detected trackers and declared dangerous permissions. Trackers
// dominate; permissions are a secondary, capped penalty.
func computePrivacyScore(trackers []PrivacyTracker, dangerousPerms []string) (int, string) {
score := 100
for _, t := range trackers {
w, ok := privacyCategoryWeight[t.Category]
if !ok {
w = defaultTrackerWeight
}
score -= w
}
// Permissions: -2 each, capped at -24 so a permission-heavy but tracker-free
// app (e.g. a camera app) isn't punished as hard as a tracker-laden one.
permPenalty := len(dangerousPerms) * 2
if permPenalty > 24 {
permPenalty = 24
}
score -= permPenalty
if score < 0 {
score = 0
}
if score > 100 {
score = 100
}
var grade string
switch {
case score >= 85:
grade = "A"
case score >= 70:
grade = "B"
case score >= 55:
grade = "C"
case score >= 40:
grade = "D"
default:
grade = "F"
}
return score, grade
}