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) + }) + } +}