From 9d61ff0ec9b61dfee4a5c1f293aaace3342dd5d6 Mon Sep 17 00:00:00 2001 From: Maher Date: Sun, 20 Sep 2026 00:03:51 +0200 Subject: [PATCH 1/3] fix(privacy): stop matching `ads` inside ordinary identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `(?i)(google.*ads|GADMobileAds|admob)` is unanchored, so `ads` matched inside `loadSdk`, `downloads`, `uploads` and `threads`. Any app that used a non-ad Google SDK and had one of those words on the same line got a CRITICAL §5.1.2 finding, which `--exit-code` turns into a failed build, and the suggested fix was to add an ATT prompt the app does not need. Require an ad-specific token instead. `\badmob` is anchored only at the start so it still covers `AdMobBanner` and `expo-ads-admob` while rejecting words that merely end in those letters. Fixes #30 --- internal/privacy/scanner.go | 2 +- internal/privacy/scanner_test.go | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/internal/privacy/scanner.go b/internal/privacy/scanner.go index 0fc7744..96e1bc7 100644 --- a/internal/privacy/scanner.go +++ b/internal/privacy/scanner.go @@ -107,7 +107,7 @@ var trackingSDKPatterns = []struct { {regexp.MustCompile(`(?i)(mixpanel)`), "Mixpanel"}, {regexp.MustCompile(`(?i)(@segment/|analytics-react-native)`), "Segment"}, {regexp.MustCompile(`(?i)(branch\.io|react-native-branch)`), "Branch"}, - {regexp.MustCompile(`(?i)(google.*ads|GADMobileAds|admob)`), "Google Ads/AdMob"}, + {regexp.MustCompile(`(?i)(googlemobileads|google-mobile-ads|GADMobileAds|GADApplicationIdentifier|\badmob)`), "Google Ads/AdMob"}, {regexp.MustCompile(`(?i)(unity.*ads|UnityAds)`), "Unity Ads"}, {regexp.MustCompile(`(?i)(applovin|AppLovinSDK)`), "AppLovin"}, {regexp.MustCompile(`(?i)(ironSource|IronSource)`), "ironSource"}, diff --git a/internal/privacy/scanner_test.go b/internal/privacy/scanner_test.go index 1335846..478c204 100644 --- a/internal/privacy/scanner_test.go +++ b/internal/privacy/scanner_test.go @@ -39,3 +39,81 @@ func TestRequiredReasonDetectsRealCallSites(t *testing.T) { } } } + +// The Google Ads/AdMob pattern used to be `google.*ads`, which is unanchored and +// case-insensitive, so `ads` matched inside ordinary identifiers — `loadSdk`, +// `downloads`, `uploads`, `threads`. Any app that touched a non-ad Google SDK and +// happened to have one of those words on the same line got a CRITICAL §5.1.2 +// finding telling it to add an ATT prompt it does not need, and `--exit-code` +// failed the build. https://github.com/RevylAI/greenlight/issues/30 +func TestGoogleAdsPatternIgnoresOrdinaryIdentifiers(t *testing.T) { + clean := []struct { + name string + file string + content string + }{ + {"google sign-in with loadSdk", "googleSignIn.ts", + "GoogleSignin: Awaited>[\"GoogleSignin\"],\n"}, + {"google with downloads", "Downloads.swift", + "let googleDriveDownloads = try await drive.downloads()\n"}, + {"google with threads", "Threads.ts", + "const googleClient = build(); // threads are reused here\n"}, + {"google with uploads", "Uploads.ts", + "import { GoogleAuthProvider } from \"firebase/auth\"; const uploads = [];\n"}, + } + + for _, tc := range clean { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, tc.file, tc.content) + + res, err := Scan(dir) + if err != nil { + t.Fatalf("Scan: %v", err) + } + for _, sdk := range res.TrackingSDKs { + if sdk == "Google Ads/AdMob" { + t.Errorf("reported Google Ads/AdMob for an app with no ad SDK; content=%q", tc.content) + } + } + }) + } +} + +// Anchoring the pattern must not cost us any real AdMob integration. These are the +// forms that appear in the file types detectLang actually scans — Swift, Objective-C +// and JS/TS. Manifest files like Info.plist, Podfile and package.json are not scanned +// at all today, so GADApplicationIdentifier is kept in the pattern for the Swift and +// Objective-C call sites rather than for the plist key. +func TestGoogleAdsPatternStillDetectsRealIntegrations(t *testing.T) { + real := []struct { + name string + file string + content string + }{ + {"swift import", "Ads.swift", "import GoogleMobileAds\n"}, + {"swift api", "Ads.swift", "GADMobileAds.sharedInstance().start(completionHandler: nil)\n"}, + {"objc identifier", "Ads.m", "NSString *key = @\"GADApplicationIdentifier\";\n"}, + {"react native import", "ads.ts", "import mobileAds from 'react-native-google-mobile-ads';\n"}, + {"expo admob component", "Banner.tsx", "import { AdMobBanner } from 'expo-ads-admob';\n"}, + {"bare admob token", "ads.js", "const admob = require('admob');\n"}, + } + + for _, tc := range real { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, tc.file, tc.content) + + res, err := Scan(dir) + if err != nil { + t.Fatalf("Scan: %v", err) + } + for _, sdk := range res.TrackingSDKs { + if sdk == "Google Ads/AdMob" { + return + } + } + t.Errorf("missed a real AdMob integration; content=%q TrackingSDKs=%v", tc.content, res.TrackingSDKs) + }) + } +} From 9666c1bb5c698642f2dc6a27f8010c8482f0b699 Mon Sep 17 00:00:00 2001 From: Maher Date: Sun, 20 Sep 2026 00:05:44 +0200 Subject: [PATCH 2/3] fix(privacy): anchor the remaining tracking SDK patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other `X.*Y` patterns share the defect fixed for Google Ads: an unanchored `.*` between two short tokens matches across unrelated code on the same line. `adjust.*sdk` is the worst — `adjustsFontSizeToFitWidth` and `adjustedContentInset` are ordinary UIKit, so any line carrying one of them and the letters `sdk` reported the Adjust SDK. `unity.*ads` matches `unityWebView.loadThreads()`. `google.*analytics`, `firebase.*analytics` and `facebook.*sdk` have the same shape. Also collapse two alternations that are duplicates under `(?i)`: `(applovin|AppLovinSDK)` and `(ironSource|IronSource)`. Mixpanel, AppsFlyer, Amplitude, Segment and Branch are left alone — they already require a distinctive brand token that cannot collide with an ordinary identifier. --- internal/privacy/scanner.go | 14 ++--- internal/privacy/scanner_test.go | 87 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/internal/privacy/scanner.go b/internal/privacy/scanner.go index 96e1bc7..fedbdaa 100644 --- a/internal/privacy/scanner.go +++ b/internal/privacy/scanner.go @@ -98,19 +98,19 @@ var trackingSDKPatterns = []struct { Pattern *regexp.Regexp Name string }{ - {regexp.MustCompile(`(?i)firebase.*analytics`), "Firebase Analytics"}, - {regexp.MustCompile(`(?i)google.*analytics`), "Google Analytics"}, - {regexp.MustCompile(`(?i)(fbsdk|facebook.*sdk)`), "Facebook SDK"}, - {regexp.MustCompile(`(?i)adjust.*sdk`), "Adjust SDK"}, + {regexp.MustCompile(`(?i)(firebaseanalytics|firebase[-/]analytics)`), "Firebase Analytics"}, + {regexp.MustCompile(`(?i)(googleanalytics|google-analytics)`), "Google Analytics"}, + {regexp.MustCompile(`(?i)(fbsdk|facebooksdk|facebook-sdk)`), "Facebook SDK"}, + {regexp.MustCompile(`(?i)(adjust[-_]?sdk|react-native-adjust|com\.adjust\b|Adjust\.(appDidLaunch|trackEvent|initSdk|getAdid))`), "Adjust SDK"}, {regexp.MustCompile(`(?i)appsflyer`), "AppsFlyer"}, {regexp.MustCompile(`(?i)(import\s+Amplitude|AmplitudeSwift|amplitude\.init|Amplitude\.instance|amplitude-js|@amplitude/)`), "Amplitude"}, {regexp.MustCompile(`(?i)(mixpanel)`), "Mixpanel"}, {regexp.MustCompile(`(?i)(@segment/|analytics-react-native)`), "Segment"}, {regexp.MustCompile(`(?i)(branch\.io|react-native-branch)`), "Branch"}, {regexp.MustCompile(`(?i)(googlemobileads|google-mobile-ads|GADMobileAds|GADApplicationIdentifier|\badmob)`), "Google Ads/AdMob"}, - {regexp.MustCompile(`(?i)(unity.*ads|UnityAds)`), "Unity Ads"}, - {regexp.MustCompile(`(?i)(applovin|AppLovinSDK)`), "AppLovin"}, - {regexp.MustCompile(`(?i)(ironSource|IronSource)`), "ironSource"}, + {regexp.MustCompile(`(?i)(unityads|unity-ads|unity3d\.ads)`), "Unity Ads"}, + {regexp.MustCompile(`(?i)applovin`), "AppLovin"}, + {regexp.MustCompile(`(?i)ironsource`), "ironSource"}, } // Scan runs the privacy analysis on a project directory. diff --git a/internal/privacy/scanner_test.go b/internal/privacy/scanner_test.go index 478c204..00aaff5 100644 --- a/internal/privacy/scanner_test.go +++ b/internal/privacy/scanner_test.go @@ -117,3 +117,90 @@ func TestGoogleAdsPatternStillDetectsRealIntegrations(t *testing.T) { }) } } + +// The remaining `X.*Y` tracking patterns had the same defect as the Google Ads one +// fixed in #30: an unanchored `.*` between two short tokens matches across unrelated +// code on the same line. `adjust.*sdk` is the worst of them — `adjustsFontSizeToFitWidth` +// and `adjustedContentInset` are ordinary UIKit, so any line carrying one of those and +// the letters `sdk` produced a CRITICAL §5.1.2 finding. +func TestTrackingPatternsIgnoreOrdinaryCode(t *testing.T) { + clean := []struct { + name string + file string + content string + notSDK string + }{ + {"uikit adjusts + sdk", "Label.swift", + "label.adjustsFontSizeToFitWidth = true // call before sdkInit()\n", "Adjust SDK"}, + {"scrollview inset + sdk", "Scroll.swift", + "scrollView.adjustedContentInset = insets; let sdkReady = true\n", "Adjust SDK"}, + {"unity webview + threads", "Unity.ts", + "unityWebView.loadThreads();\n", "Unity Ads"}, + {"unity bridge + uploads", "Bridge.ts", + "const unityBridge = init(); const uploads = [];\n", "Unity Ads"}, + {"google id + analytics flag", "config.ts", + "export const cfg = { googleClientId: ID, analyticsEnabled: false };\n", "Google Analytics"}, + {"firebase auth + analytics word", "auth.ts", + "import { getAuth } from 'firebase/auth'; // analytics intentionally omitted\n", "Firebase Analytics"}, + {"facebook login without sdk token", "fb.ts", + "// the facebook login flow was removed; see sdkMigration.md\n", "Facebook SDK"}, + } + + for _, tc := range clean { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, tc.file, tc.content) + + res, err := Scan(dir) + if err != nil { + t.Fatalf("Scan: %v", err) + } + for _, sdk := range res.TrackingSDKs { + if sdk == tc.notSDK { + t.Errorf("reported %q for ordinary code; content=%q", tc.notSDK, tc.content) + } + } + }) + } +} + +// Anchoring those patterns must not lose the integrations they exist to catch. +func TestTrackingPatternsStillDetectRealSDKs(t *testing.T) { + real := []struct { + name string + file string + content string + wantSDK string + }{ + {"adjust swift import", "A.swift", "import AdjustSdk\n", "Adjust SDK"}, + {"adjust api call", "A.swift", "Adjust.appDidLaunch(adjustConfig)\n", "Adjust SDK"}, + {"adjust react native", "a.ts", "import { Adjust } from 'react-native-adjust';\n", "Adjust SDK"}, + {"unity ads swift", "U.swift", "import UnityAds\n", "Unity Ads"}, + {"unity ads package", "u.ts", "import { UnityAds } from 'unity-ads-react-native';\n", "Unity Ads"}, + {"google analytics", "g.ts", "import ga from 'react-native-google-analytics';\n", "Google Analytics"}, + {"firebase analytics rn", "f.ts", "import analytics from '@react-native-firebase/analytics';\n", "Firebase Analytics"}, + {"firebase analytics swift", "F.swift", "import FirebaseAnalytics\n", "Firebase Analytics"}, + {"facebook sdk rn", "fb.ts", "import { Settings } from 'react-native-fbsdk-next';\n", "Facebook SDK"}, + {"facebook sdk ios", "FB.m", "#import \n", "Facebook SDK"}, + {"applovin", "al.swift", "import AppLovinSDK\n", "AppLovin"}, + {"ironsource", "is.swift", "import IronSource\n", "ironSource"}, + } + + for _, tc := range real { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, tc.file, tc.content) + + res, err := Scan(dir) + if err != nil { + t.Fatalf("Scan: %v", err) + } + for _, sdk := range res.TrackingSDKs { + if sdk == tc.wantSDK { + return + } + } + t.Errorf("missed %q; content=%q TrackingSDKs=%v", tc.wantSDK, tc.content, res.TrackingSDKs) + }) + } +} From ab79e2ee3827bafa761db9c487b2b7da520abc89 Mon Sep 17 00:00:00 2001 From: Maher Date: Sun, 20 Sep 2026 00:36:15 +0200 Subject: [PATCH 3/3] fix(privacy): keep the Firebase and Facebook JS forms matching Review catch: anchoring those two patterns lost integrations the old ones detected. Firebase only allowed `firebase` and `analytics` to be adjacent or joined by a single `-` or `/`, which drops the namespaced `firebase.analytics()` API and the `firebase/compat/analytics` import path. Allow `.` as a separator and the `compat/` segment. Facebook required `fbsdk`, `facebooksdk` or `facebook-sdk`, which drops `facebook-ios-sdk` and the `facebook-jssdk` script id used by the web loader snippet. Allow the `ios-`, `android-` and `js` infixes. Neither widening reintroduces the false positives: both still require the tokens to be joined, so `firebase/auth` followed by the word analytics, and `facebook` followed by a separate `sdk`, stay clean. --- internal/privacy/scanner.go | 4 ++-- internal/privacy/scanner_test.go | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/privacy/scanner.go b/internal/privacy/scanner.go index fedbdaa..36eedce 100644 --- a/internal/privacy/scanner.go +++ b/internal/privacy/scanner.go @@ -98,9 +98,9 @@ var trackingSDKPatterns = []struct { Pattern *regexp.Regexp Name string }{ - {regexp.MustCompile(`(?i)(firebaseanalytics|firebase[-/]analytics)`), "Firebase Analytics"}, + {regexp.MustCompile(`(?i)(firebaseanalytics|firebase[-/.](?:compat/)?analytics)`), "Firebase Analytics"}, {regexp.MustCompile(`(?i)(googleanalytics|google-analytics)`), "Google Analytics"}, - {regexp.MustCompile(`(?i)(fbsdk|facebooksdk|facebook-sdk)`), "Facebook SDK"}, + {regexp.MustCompile(`(?i)(fbsdk|facebook-?(?:ios-|android-|js)?sdk)`), "Facebook SDK"}, {regexp.MustCompile(`(?i)(adjust[-_]?sdk|react-native-adjust|com\.adjust\b|Adjust\.(appDidLaunch|trackEvent|initSdk|getAdid))`), "Adjust SDK"}, {regexp.MustCompile(`(?i)appsflyer`), "AppsFlyer"}, {regexp.MustCompile(`(?i)(import\s+Amplitude|AmplitudeSwift|amplitude\.init|Amplitude\.instance|amplitude-js|@amplitude/)`), "Amplitude"}, diff --git a/internal/privacy/scanner_test.go b/internal/privacy/scanner_test.go index 00aaff5..f0472cc 100644 --- a/internal/privacy/scanner_test.go +++ b/internal/privacy/scanner_test.go @@ -179,9 +179,14 @@ func TestTrackingPatternsStillDetectRealSDKs(t *testing.T) { {"unity ads package", "u.ts", "import { UnityAds } from 'unity-ads-react-native';\n", "Unity Ads"}, {"google analytics", "g.ts", "import ga from 'react-native-google-analytics';\n", "Google Analytics"}, {"firebase analytics rn", "f.ts", "import analytics from '@react-native-firebase/analytics';\n", "Firebase Analytics"}, + {"firebase analytics modular", "f2.ts", "import { getAnalytics } from 'firebase/analytics';\n", "Firebase Analytics"}, + {"firebase analytics namespaced", "f3.js", "firebase.analytics().logEvent('open');\n", "Firebase Analytics"}, + {"firebase analytics compat", "f4.ts", "import 'firebase/compat/analytics';\n", "Firebase Analytics"}, {"firebase analytics swift", "F.swift", "import FirebaseAnalytics\n", "Firebase Analytics"}, {"facebook sdk rn", "fb.ts", "import { Settings } from 'react-native-fbsdk-next';\n", "Facebook SDK"}, {"facebook sdk ios", "FB.m", "#import \n", "Facebook SDK"}, + {"facebook ios sdk spm", "fb2.ts", "const dep = 'https://github.com/facebook/facebook-ios-sdk';\n", "Facebook SDK"}, + {"facebook jssdk loader", "fb3.js", "js.id = 'facebook-jssdk';\n", "Facebook SDK"}, {"applovin", "al.swift", "import AppLovinSDK\n", "AppLovin"}, {"ironsource", "is.swift", "import IronSource\n", "ironSource"}, }