diff --git a/internal/codescan/claims.go b/internal/codescan/claims.go index 0439a6e..d3285f7 100644 --- a/internal/codescan/claims.go +++ b/internal/codescan/claims.go @@ -29,13 +29,13 @@ type Claims struct { // rules (account-no-delete, iap-no-restore, social-login-no-apple). Kept in sync // deliberately: the runtime tier re-checks exactly the flows static can only guess. var ( - reAccountCreation = regexp.MustCompile(`(?i)(createAccount|signUp|register.*user|create.*account|auth\(\)\.createUser)`) - reIAP = regexp.MustCompile(`(?i)(SKPaymentQueue|StoreKit|Product\.purchase|purchaseProduct|expo-in-app-purchases|react-native-iap|RevenueCat)`) + reAccountCreation = regexp.MustCompile(`(?i)(createAccount|signUp|register.*user\b|create.*account|auth\(\)\.createUser)`) + reIAP = regexp.MustCompile(`(?i)(SKPaymentQueue|StoreKit|Product\.purchase|purchaseProduct|expo-in-app-purchases|react-native-iap|RevenueCat|UnityEngine\.Purchasing|UnityPurchasing\.|IStoreListener|IDetailedStoreListener|CodelessIAPStoreListener)`) reSocialLogin = regexp.MustCompile(`(?i)(google.*sign.*in|GIDSignIn|GoogleSignin|facebook.*login|FBSDKLoginManager|LoginManager\.logIn)`) reDeleteAccount = regexp.MustCompile(`(?i)(deleteAccount|delete.*account|remove.*account|account.*delet|close.*account|closeAccount|cancel.*account|delete.*my.*account|erase.*account)`) - reRestore = regexp.MustCompile(`(?i)(restoreCompletedTransactions|restore.*purchase|restorePurchase|customerInfo|syncPurchases)`) - reSiwA = regexp.MustCompile(`(?i)(ASAuthorizationAppleIDProvider|SignInWithApple|apple.*auth|appleAuth|expo-apple-authentication)`) + reRestore = regexp.MustCompile(`(?i)(restoreCompletedTransactions|restore.*purchase|restorePurchase|customerInfo|syncPurchases|RestoreTransactions)`) + reSiwA = regexp.MustCompile(`(?i)(ASAuthorizationAppleIDProvider|SignInWithApple|apple.*auth|appleAuth|expo-apple-authentication|apple[_\s-]*sign[_\s-]?in([^gG]|$)|sign[_\s-]?in[_\s-]?with[_\s-]?apple)`) ) // DetectClaims walks the project — reusing the scanner's file collection and @@ -50,7 +50,7 @@ func DetectClaims(root string) (Claims, error) { var c Claims for _, fc := range files { switch fc.Language { - case "swift", "objc", "typescript", "javascript": + case "swift", "objc", "typescript", "javascript", "csharp": default: continue // ignore plist/json config — claims live in source } diff --git a/internal/codescan/csharp_unity_test.go b/internal/codescan/csharp_unity_test.go new file mode 100644 index 0000000..3fab925 --- /dev/null +++ b/internal/codescan/csharp_unity_test.go @@ -0,0 +1,373 @@ +package codescan + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func csCtx(lines ...string) FileContext { + return FileContext{Path: "X.cs", RelPath: "X.cs", Lines: lines, Language: "csharp"} +} + +func TestDetectLanguageCSharp(t *testing.T) { + if got := detectLanguage("Assets/Scripts/Shop.cs"); got != "csharp" { + t.Errorf("detectLanguage(.cs) = %q, want csharp", got) + } +} + +// Unity IAP usage (UnityEngine.Purchasing / IStoreListener) must trigger the +// iap-no-restore rule on C# sources, and Unity's restore path +// (IAppleExtensions.RestoreTransactions) must count as the anti-pattern. +func TestUnityIAPRule(t *testing.T) { + r := ruleByID(t, "iap-no-restore") + + buy := csCtx(`public class ShopManager : MonoBehaviour, IStoreListener {`) + if !r.Applies(buy) { + t.Fatal("iap-no-restore should apply to csharp files") + } + if got := r.Check(buy); len(got) == 0 { + t.Error("expected a finding for Unity IAP without restore") + } + + restore := csCtx(`extensions.GetExtension().RestoreTransactions(OnRestore);`) + if !r.AntiPatternMatched(restore) { + t.Error("RestoreTransactions via IAppleExtensions should suppress iap-no-restore") + } +} + +// registerDefaults:@{@"UserAgent"...} — UIKit's user-agent override — must NOT +// count as account creation. Real signup entry points still must. +func TestAccountCreationUserAgentFalsePositive(t *testing.T) { + r := ruleByID(t, "account-no-delete") + + fp := FileContext{Path: "W.mm", RelPath: "W.mm", Language: "objc", Lines: []string{ + ` [[NSUserDefaults standardUserDefaults] registerDefaults:@{ @"UserAgent": ua }];`, + }} + if got := r.Check(fp); len(got) != 0 { + t.Errorf("registerDefaults/UserAgent must not read as account signup, got %+v", got) + } + + real := []FileContext{ + csCtx(`public void RegisterUser(string email) {`), + swiftCtx(`func createAccount(email: String) {`), + csCtx(`authService.SignUp(email, password);`), + } + for i, ctx := range real { + if got := r.Check(ctx); len(got) == 0 { + t.Errorf("case %d: expected signup detection, got none", i) + } + } +} + +// A file whose UIWebView usage sits behind a deployment-target guard +// (__IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0) never compiles into a +// modern binary and must not trip the CRITICAL uiwebview-removed rule. +func TestUIWebViewDeploymentGuardSuppression(t *testing.T) { + r := ruleByID(t, "uiwebview-removed") + + guarded := objcCtx( + `#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0`, + ` UIWebView *uiwebview = [[UIWebView alloc] initWithFrame:view.frame];`, + `#endif`, + ) + if got := r.Check(guarded); len(got) != 0 { + t.Errorf("deployment-guarded UIWebView must be suppressed, got %+v", got) + } + + live := objcCtx(` UIWebView *uiwebview = [[UIWebView alloc] initWithFrame:view.frame];`) + if got := r.Check(live); len(got) == 0 { + t.Error("unguarded UIWebView usage must still be CRITICAL") + } +} + +// Suppression must cover the guarded branch ONLY. Skipping the whole file +// whenever the guard appears anywhere would hide shipping UIWebView usage that +// follows the #endif — a false negative on a hard-rejection rule, which is far +// worse than the false positive the guard handling exists to prevent. +func TestUIWebViewGuardSuppressionIsLineScoped(t *testing.T) { + r := ruleByID(t, "uiwebview-removed") + + cases := []struct { + name string + fc FileContext + }{ + {"usage after #endif", objcCtx( + `#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0`, + ` // legacy helper`, + `#endif`, + ` UIWebView *live = [[UIWebView alloc] init];`, + )}, + {"usage in the #else branch", objcCtx( + `#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0`, + ` WKWebView *modern = [[WKWebView alloc] init];`, + `#else`, + ` UIWebView *live = [[UIWebView alloc] init];`, + `#endif`, + )}, + {"usage before the guard", objcCtx( + ` UIWebView *live = [[UIWebView alloc] init];`, + `#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0`, + `#endif`, + )}, + {"unbalanced directives suppress nothing", objcCtx( + `#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0`, + ` UIWebView *live = [[UIWebView alloc] init];`, + )}, + } + for _, tc := range cases { + if got := r.Check(tc.fc); len(got) == 0 { + t.Errorf("%s: live UIWebView must still be reported", tc.name) + } + } + + // Nested conditionals inside the dead branch stay dead. + nested := objcCtx( + `#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0`, + `#ifdef DEBUG`, + ` UIWebView *dead = [[UIWebView alloc] init];`, + `#endif`, + `#endif`, + ) + if got := r.Check(nested); len(got) != 0 { + t.Errorf("nested dead branch must stay suppressed, got %+v", got) + } +} + +// An anti-pattern asserts a feature is implemented. A comment saying the +// opposite must never satisfy it — otherwise a TODO disables the rule project +// -wide. String literals must still count: SDK-driven implementations name +// their providers in strings. +func TestAntiPatternIgnoresComments(t *testing.T) { + r := ruleByID(t, "social-login-no-apple") + + denied := []FileContext{ + csCtx(`// TODO: Apple Sign In is not supported yet — planned for next sprint`), + csCtx(`/* apple sign in: not implemented */`), + } + for _, fc := range denied { + if r.AntiPatternMatched(fc) { + t.Errorf("a comment must not count as SIWA evidence: %q", fc.Lines[0]) + } + } + + allowed := []FileContext{ + csCtx(` "APPLE_SIGNIN_RESULT_CANCELED",`), + swiftCtx(`let provider = ASAuthorizationAppleIDProvider()`), + csCtx(`auth.SignInWithApple(); // kick off the native sheet`), + } + for _, fc := range allowed { + if !r.AntiPatternMatched(fc) { + t.Errorf("real SIWA evidence must still count: %q", fc.Lines[0]) + } + } +} + +// Comment stripping must not mistake `//` inside a string literal for a +// comment, and must carry /* */ state across lines so a block comment's +// continuation lines are stripped too. +func TestStripCommentsMultiline(t *testing.T) { + got := stripCommentsMultiline([]string{ + `var url = "https://example.com/path"; // trailing note`, + `/* TODO:`, + ` Sign in with Apple is not supported yet`, + `*/`, + `var live = "kept";`, + }) + + if !strings.Contains(got[0], "https://example.com/path") { + t.Errorf("URL inside a string literal was clipped: %q", got[0]) + } + if strings.Contains(got[0], "trailing note") { + t.Errorf("trailing comment was not stripped: %q", got[0]) + } + if strings.Contains(got[2], "Apple") { + t.Errorf("block-comment continuation line was not stripped: %q", got[2]) + } + if !strings.Contains(got[4], "kept") { + t.Errorf("code after the block comment was lost: %q", got[4]) + } +} + +// A multi-line block comment must not satisfy an anti-pattern. Line-local +// stripping left continuation lines exposed, so a TODO spanning two lines could +// still disable the rule project-wide. +func TestAntiPatternIgnoresBlockCommentContinuation(t *testing.T) { + r := ruleByID(t, "social-login-no-apple") + + fc := csCtx( + `/*`, + ` * TODO: Sign in with Apple is not supported yet.`, + ` */`, + `public void LoginWithGoogle() { }`, + ) + if r.AntiPatternMatched(fc) { + t.Error("a multi-line block comment must not count as SIWA evidence") + } +} + +// Apple code signing appears in every iOS build script. It must not read as +// Sign in with Apple, or one build file disables the 4.8 rule project-wide. +func TestSIWADoesNotMatchCodeSigning(t *testing.T) { + r := ruleByID(t, "social-login-no-apple") + + denied := []string{ + `private const string APPLE_SIGNING_TEAM = "ABC123";`, + `// re-signing with apple distribution certificate`, + `var appleSigningIdentity = GetIdentity();`, + } + for _, line := range denied { + if r.AntiPatternMatched(csCtx(line)) { + t.Errorf("code-signing text must not count as SIWA: %q", line) + } + } + + allowed := []string{ + ` "APPLE_SIGNIN_RESULT_CANCELED",`, + `var provider = "apple-signin";`, + `auth.appleSignIn();`, + `showButton("Sign in with Apple");`, + } + for _, line := range allowed { + if !r.AntiPatternMatched(csCtx(line)) { + t.Errorf("real SIWA evidence must still count: %q", line) + } + } +} + +// IAppleExtensions also carries deferred purchases, receipts and promo helpers. +// Only the RestoreTransactions call proves a restore path exists. +func TestRestoreRequiresTheActualCall(t *testing.T) { + r := ruleByID(t, "iap-no-restore") + + receiptOnly := csCtx(`var apple = extensions.GetExtension();`) + if r.AntiPatternMatched(receiptOnly) { + t.Error("bare IAppleExtensions must not count as a restore implementation") + } + + real := csCtx(`extensions.GetExtension().RestoreTransactions(OnRestore);`) + if !r.AntiPatternMatched(real) { + t.Error("RestoreTransactions must count as a restore implementation") + } +} + +// Unity's generated directories (Library alone often holds 100k+ files of +// engine cache) must be skipped — but only when the root actually is a Unity +// project, so a non-Unity repo with a Library/ folder keeps full coverage. +func TestUnityGeneratedDirs(t *testing.T) { + unity := t.TempDir() + if err := os.MkdirAll(filepath.Join(unity, "ProjectSettings"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(unity, "ProjectSettings", "ProjectSettings.asset"), []byte("m_EditorVersion: 6000.3.9f1\n"), 0o644); err != nil { + t.Fatal(err) + } + dirs := UnityGeneratedDirs(unity) + if !dirs[filepath.Join(unity, "Library")] || !dirs[filepath.Join(unity, "Temp")] { + t.Errorf("Unity project should skip root Library/Temp, got %v", dirs) + } + + // Only the root-level directories. Game source under Assets/Scripts/Logs or a + // plugin's own Library/ must stay in scope. + for _, nested := range []string{ + filepath.Join(unity, "Assets", "Scripts", "Logs"), + filepath.Join(unity, "Assets", "Plugins", "SomeSDK", "Library"), + } { + if dirs[nested] { + t.Errorf("nested folder must not be skipped: %s", nested) + } + } + + if dirs := UnityGeneratedDirs(t.TempDir()); dirs != nil { + t.Errorf("non-Unity project must not skip anything, got %v", dirs) + } +} + +// End-to-end: a .cs file under Assets/Scripts/Logs must still be scanned, while +// the root Library/ is skipped. +func TestNestedUnityLikeFoldersStayInScope(t *testing.T) { + root := t.TempDir() + mk := func(parts ...string) string { + t.Helper() + dir := filepath.Join(append([]string{root}, parts...)...) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + return dir + } + write := func(dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + write(mk("ProjectSettings"), "ProjectSettings.asset", "m_EditorVersion: 6000.3.9f1\n") + write(mk("Assets", "Scripts", "Logs"), "Logger.cs", "class Logger {}\n") + write(mk("Library", "ScriptAssemblies"), "Cached.cs", "class Cached {}\n") + + files, err := (&Scanner{root: root}).collectFiles() + if err != nil { + t.Fatalf("collectFiles: %v", err) + } + + var sawNested, sawLibrary bool + for _, f := range files { + if strings.Contains(f.RelPath, "Logger.cs") { + sawNested = true + } + if strings.Contains(f.RelPath, "Cached.cs") { + sawLibrary = true + } + } + if !sawNested { + t.Error("Assets/Scripts/Logs/Logger.cs must be scanned") + } + if sawLibrary { + t.Error("root Library/ must be skipped") + } +} + +// Platform SDKs abstract Sign in with Apple behind provider constants +// (APPLE_SIGNIN_RESULT_CANCELED, provider "apple-signin") without ever naming +// ASAuthorization*. Those must count as the SIWA anti-pattern, mirroring how +// google.*sign.*in counts as the Google-login trigger. +func TestSIWAViaProviderConstants(t *testing.T) { + r := ruleByID(t, "social-login-no-apple") + + siwa := csCtx(` "APPLE_SIGNIN_RESULT_CANCELED",`) + if !r.AntiPatternMatched(siwa) { + t.Error("APPLE_SIGNIN_* provider constant should count as Sign in with Apple") + } + + unrelated := csCtx(`private const string PROVIDER_APPLE = "apple";`) + if r.AntiPatternMatched(unrelated) { + t.Error(`a bare "apple" provider constant alone should NOT count as SIWA`) + } +} + +// DetectClaims must see C# sources: Unity IAP claims the IAP flow, and a C# +// delete-account implementation counts as the delete anti-pattern. +func TestDetectClaimsCSharp(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("Shop.cs", "using UnityEngine.Purchasing;\nclass Shop : IStoreListener {}\n") + write("Account.cs", "public void DeleteAccount() { api.Post(\"/account/delete\"); }\n") + + c, err := DetectClaims(dir) + if err != nil { + t.Fatalf("DetectClaims: %v", err) + } + if !c.IAP { + t.Error("Unity IAP in C# should claim IAP") + } + if !c.HasDeleteAccountCode { + t.Error("C# DeleteAccount should count as delete-account code") + } +} diff --git a/internal/codescan/preproc.go b/internal/codescan/preproc.go new file mode 100644 index 0000000..3d85216 --- /dev/null +++ b/internal/codescan/preproc.go @@ -0,0 +1,78 @@ +package codescan + +import "regexp" + +var ( + rePreprocIf = regexp.MustCompile(`^\s*#\s*if(n?def)?\b`) + rePreprocElse = regexp.MustCompile(`^\s*#\s*else\b`) + rePreprocElif = regexp.MustCompile(`^\s*#\s*elif\b`) + rePreprocEndif = regexp.MustCompile(`^\s*#\s*endif\b`) +) + +// deadLines reports which line indices sit inside a preprocessor branch that one +// of guards proves the compiler never emits — e.g. a legacy shim wrapped in +// `#if __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0`. +// +// Suppression must be line-precise. Treating "the guard appears somewhere in +// this file" as "the whole file is dead" would hide live code that follows the +// matching #endif, turning a false positive into the far worse false negative of +// a CRITICAL rejection reason going unreported. +// +// Every ambiguity resolves toward "live", so a finding is reported rather than +// swallowed: conditions we cannot evaluate (#elif, non-guard #if) are live, and +// an unbalanced file suppresses nothing at all. +func deadLines(lines []string, guards []*regexp.Regexp) map[int]bool { + // guarded records whether this level's branch state is one we actually + // derived from a guard — only then may #else flip it. + type frame struct{ guarded, dead bool } + var stack []frame + + dead := make(map[int]bool) + inDead := func() bool { + for _, f := range stack { + if f.dead { + return true + } + } + return false + } + + for i, line := range lines { + switch { + case rePreprocIf.MatchString(line): + g := matchesAny(guards, line) + stack = append(stack, frame{guarded: g, dead: g}) + case rePreprocElse.MatchString(line) && len(stack) > 0: + if top := &stack[len(stack)-1]; top.guarded { + top.dead = !top.dead + } + case rePreprocElif.MatchString(line) && len(stack) > 0: + // The new condition is not the guard we matched, and we do not + // evaluate preprocessor expressions — assume the branch ships. + if top := &stack[len(stack)-1]; top.guarded { + top.guarded, top.dead = false, false + } + case rePreprocEndif.MatchString(line) && len(stack) > 0: + stack = stack[:len(stack)-1] + } + + if inDead() { + dead[i] = true + } + } + + // Unbalanced directives mean we lost track of the nesting; suppress nothing. + if len(stack) != 0 { + return nil + } + return dead +} + +func matchesAny(patterns []*regexp.Regexp, s string) bool { + for _, p := range patterns { + if p.MatchString(s) { + return true + } + } + return false +} diff --git a/internal/codescan/rules.go b/internal/codescan/rules.go index d509a29..550f93d 100644 --- a/internal/codescan/rules.go +++ b/internal/codescan/rules.go @@ -32,7 +32,7 @@ func AllRules() []Rule { severity: SeverityCritical, detail: "Hardcoded secrets in source code is a security vulnerability and review risk.", fix: "Move secrets to environment variables or a secure keychain.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)(sk_live_|sk_test_|pk_live_|pk_test_)[a-zA-Z0-9]{20,}`), regexp.MustCompile(`(?i)(api[_-]?key|api[_-]?secret|secret[_-]?key)\s*[:=]\s*["'][a-zA-Z0-9]{20,}["']`), @@ -47,7 +47,7 @@ func AllRules() []Rule { severity: SeverityCritical, detail: "Using Stripe/PayPal/external payments for digital goods violates IAP requirements. Physical goods are OK.", fix: "Use StoreKit/IAP for digital goods. External payment is only allowed for physical goods and services.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)stripe.*payment.*intent`), regexp.MustCompile(`(?i)paypal.*checkout`), @@ -62,7 +62,7 @@ func AllRules() []Rule { severity: SeverityCritical, detail: "On-device cryptocurrency mining is explicitly prohibited.", fix: "Remove all mining functionality.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)(crypto|coin)\s*miner`), regexp.MustCompile(`(?i)hash\s*rate`), @@ -93,14 +93,17 @@ func AllRules() []Rule { severity: SeverityHigh, detail: "Using advertising or tracking SDKs requires App Tracking Transparency.", fix: "Implement ATT prompt before any tracking. Add NSUserTrackingUsageDescription to Info.plist.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)(firebase.*analytics|google.*analytics|facebook.*sdk|fbsdk|adjust.*sdk|appsflyer|mixpanel)`), regexp.MustCompile(`(?i)(import\s+Amplitude|AmplitudeSwift|amplitude\.init|Amplitude\.instance|amplitude-js|@amplitude/)`), regexp.MustCompile(`(?i)(import.*@segment/|analytics-react-native|SegmentAnalytics|createClient.*writeKey)`), }, antiPatterns: []*regexp.Regexp{ - regexp.MustCompile(`(?i)(ATTrackingManager|requestTrackingAuthorization|AppTrackingTransparency|expo-tracking-transparency)`), + // ATTrackingStatusBinding is Unity's iOS-14 advertising-support ATT + // binding; NSUserTrackingUsageDescription in source means a build + // post-processor injects the ATT purpose string into Info.plist. + regexp.MustCompile(`(?i)(ATTrackingManager|requestTrackingAuthorization|AppTrackingTransparency|expo-tracking-transparency|ATTrackingStatusBinding|NSUserTrackingUsageDescription)`), }, antiPatternsGlobal: true, firstMatchOnly: true, @@ -112,12 +115,16 @@ func AllRules() []Rule { severity: SeverityHigh, detail: "Apps with third-party login (Google, Facebook, etc.) must also offer Sign in with Apple.", fix: "Add Sign in with Apple as a login option alongside other social logins.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)(google.*sign.*in|GIDSignIn|GoogleSignin|facebook.*login|FBSDKLoginManager|LoginManager\.logIn)`), }, antiPatterns: []*regexp.Regexp{ - regexp.MustCompile(`(?i)(ASAuthorizationAppleIDProvider|SignInWithApple|apple.*auth|appleAuth|expo-apple-authentication)`), + // Auth SDKs abstract SIWA behind provider constants like + // APPLE_SIGNIN_RESULT_*, never naming ASAuthorization* directly. + // The trailing [^gG] keeps "apple ... signing" (code signing, which + // appears in every iOS build script) from passing as sign-IN. + regexp.MustCompile(`(?i)(ASAuthorizationAppleIDProvider|SignInWithApple|apple.*auth|appleAuth|expo-apple-authentication|apple[_\s-]*sign[_\s-]?in([^gG]|$)|sign[_\s-]?in[_\s-]?with[_\s-]?apple)`), }, antiPatternsGlobal: true, firstMatchOnly: true, @@ -129,12 +136,19 @@ func AllRules() []Rule { severity: SeverityHigh, detail: "Apps with IAP must include a 'Restore Purchases' button.", fix: "Add a 'Restore Purchases' button that calls restoreCompletedTransactions or equivalent.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)(SKPaymentQueue|StoreKit|Product\.purchase|purchaseProduct|expo-in-app-purchases|react-native-iap|RevenueCat)`), + // Unity IAP: UnityEngine.Purchasing namespace, its store listener + // interfaces, and the codeless IAP path. + regexp.MustCompile(`(?i)(UnityEngine\.Purchasing|UnityPurchasing\.|IStoreListener|IDetailedStoreListener|CodelessIAPStoreListener)`), }, antiPatterns: []*regexp.Regexp{ - regexp.MustCompile(`(?i)(restoreCompletedTransactions|restore.*purchase|restorePurchase|customerInfo|syncPurchases)`), + // RestoreTransactions is Unity IAP's restore call. Matching the + // IAppleExtensions interface itself would be wrong — it also carries + // deferred purchases, receipts and promo helpers, so an app that only + // reads a receipt would look like it implements restore. + regexp.MustCompile(`(?i)(restoreCompletedTransactions|restore.*purchase|restorePurchase|customerInfo|syncPurchases|RestoreTransactions)`), }, antiPatternsGlobal: true, firstMatchOnly: true, @@ -146,9 +160,14 @@ func AllRules() []Rule { severity: SeverityHigh, detail: "Apps that allow account creation must also offer account deletion functionality.", fix: "Add an account deletion option in settings. Must actually delete data, not just deactivate.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ - regexp.MustCompile(`(?i)(createAccount|signUp|register.*user|create.*account|auth\(\)\.createUser)`), + // `user` needs a trailing word boundary: without it, `register.*user` + // matched registerDefaults:@{@"UserAgent"...} and flagged every app + // that customizes a webview user agent as having account signup. + // (registerUser( still matches — `(` is a boundary; UserAgent's + // `user` is followed by a letter and no longer does.) + regexp.MustCompile(`(?i)(createAccount|signUp|register.*user\b|create.*account|auth\(\)\.createUser)`), }, antiPatterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)(deleteAccount|delete.*account|remove.*account|account.*delet|close.*account|closeAccount|cancel.*account|delete.*my.*account|erase.*account)`), @@ -165,7 +184,7 @@ func AllRules() []Rule { severity: SeverityWarn, detail: "Mentioning other platforms (Android, Google Play, etc.) in user-facing strings may cause rejection.", fix: "Remove references to competing platforms from all user-visible text.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ // Only flag the keyword inside a string/JSX literal — user-facing // copy is the §2.3 risk. A bare unquoted match flagged every React @@ -179,6 +198,10 @@ func AllRules() []Rule { // not user-facing copy: RN platform branches, imports/requires, // package names, file paths, and build config. regexp.MustCompile(`(?i)(Platform\.|import |require\(|from\s+['"][\w@./-]+['"]|@react-native|androidx|\.android\b|/android/|BuildConfig|\.gradle)`), + // Unity platform branches and build pipeline idioms — RuntimePlatform + // checks, BuildTarget switches, and UNITY_ANDROID defines all carry + // the word "Android" without ever reaching user-facing copy. + regexp.MustCompile(`(?i)(RuntimePlatform\.|BuildTarget\.|UNITY_ANDROID|UNITY_IOS|Application\.platform|PlayerSettings\.)`), }, }, &PatternRule{ @@ -188,7 +211,7 @@ func AllRules() []Rule { severity: SeverityWarn, detail: "Placeholder text will cause rejection under App Completeness guidelines.", fix: "Replace all placeholder text with final content.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)"[^"]*\b(lorem ipsum|coming soon|under construction|todo|tbd)\b[^"]*"`), regexp.MustCompile(`(?i)'[^']*\b(lorem ipsum|coming soon|under construction|todo|tbd)\b[^']*'`), @@ -219,7 +242,7 @@ func AllRules() []Rule { severity: SeverityWarn, detail: "Apps must support IPv6. Hardcoded IPv4 addresses will fail on IPv6-only networks.", fix: "Use hostnames instead of IP addresses. Ensure all networking supports IPv6.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ // Require valid 0-255 octets so version/build strings like // "2020.10.5.1" or "999.1.2.3" aren't mistaken for an IPv4 address. @@ -236,7 +259,7 @@ func AllRules() []Rule { severity: SeverityWarn, detail: "App Transport Security requires HTTPS. HTTP URLs will be blocked by default.", fix: "Use HTTPS for all network requests.", - languages: []string{"swift", "objc", "typescript", "javascript"}, + languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, patterns: []*regexp.Regexp{ regexp.MustCompile(`"http://[^"]+"`), regexp.MustCompile(`'http://[^']+'`), @@ -275,6 +298,14 @@ func AllRules() []Rule { regexp.MustCompile(`[:\[]\s*UIWebView\b`), // : UIWebView / [UIWebView }, codeOnly: true, + deadCodeGuards: []*regexp.Regexp{ + // Legacy compatibility shims (e.g. unity-webview's + // WebViewWithUIWebView.mm) put their UIWebView path behind a + // deployment-target guard. No modern app targets below iOS 9-12, so + // that branch never compiles in and cannot trip ITMS-90809 — but only + // that branch: usage after the #endif still ships, and still fails. + regexp.MustCompile(`__IPHONE_OS_VERSION_MIN_REQUIRED\s*<\s*__IPHONE_(7_0|8_0|9_0|10_0|11_0|12_0)\b`), + }, }, &PatternRule{ id: "vague-purpose-string", @@ -325,6 +356,13 @@ type PatternRule struct { countThreshold int // Only report if count exceeds this firstMatchOnly bool // Project-level fact: cap to one per file; scanner collapses to one per project codeOnly bool // Strip string literals + comments before matching (for rules that must not fire on text) + + // deadCodeGuards identify preprocessor conditions whose branch never reaches + // a shipping build (e.g. deployment targets nobody supports anymore). Matches + // inside such a branch are skipped; matches after the matching #endif are + // not. Line-scope ignorePatterns can't express this — the guard and the match + // sit on different lines. + deadCodeGuards []*regexp.Regexp } func (r *PatternRule) RuleID() string { return r.id } @@ -334,9 +372,17 @@ func (r *PatternRule) HasGlobalAntiPatterns() bool { } func (r *PatternRule) AntiPatternMatched(fc FileContext) bool { - for _, line := range fc.Lines { + // An anti-pattern asserts the feature IS implemented, and a comment is never + // evidence of that — `// TODO: Sign in with Apple not supported yet` says the + // opposite, yet would otherwise suppress the rule project-wide. Stripping runs + // over the whole file so a /* … */ block spanning lines is fully removed; + // line-local stripping would leave its continuation lines exposed. + // + // String literals stay: SDK-driven implementations name their providers in + // strings (e.g. an "APPLE_SIGNIN_RESULT_CANCELED" error-code constant). + for _, code := range stripCommentsMultiline(fc.Lines) { for _, ap := range r.antiPatterns { - if ap.MatchString(line) { + if ap.MatchString(code) { return true } } @@ -356,7 +402,16 @@ func (r *PatternRule) Applies(fc FileContext) bool { func (r *PatternRule) Check(fc FileContext) []Finding { var findings []Finding + var dead map[int]bool + if len(r.deadCodeGuards) > 0 { + dead = deadLines(fc.Lines, r.deadCodeGuards) + } + for lineNum, line := range fc.Lines { + if dead[lineNum] { + continue + } + // Skip comment lines trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") { @@ -422,6 +477,54 @@ func (r *PatternRule) Check(fc FileContext) []Finding { // and removes // line comments and /* */ block comments, so codeOnly rules match // only real code. It's a lightweight scan (no escaped-quote handling), which is // enough to keep call-shaped text like "UIWebView()" out of the match. +// stripCommentsMultiline blanks comments across a file while preserving string +// literals, carrying /* … */ state between lines so a block comment's +// continuation lines are stripped too. It is string-aware, so a `//` inside a +// literal (e.g. "https://example.com") is not mistaken for a comment. +func stripCommentsMultiline(lines []string) []string { + out := make([]string, len(lines)) + inBlock := false + + for i, line := range lines { + var b strings.Builder + b.Grow(len(line)) + inStr := false + var quote byte + + scan: + for j := 0; j < len(line); j++ { + c := line[j] + switch { + case inBlock: + if c == '*' && j+1 < len(line) && line[j+1] == '/' { + inBlock = false + j++ + } + b.WriteByte(' ') + case inStr: + b.WriteByte(c) + if c == quote { + inStr = false + } + case c == '"' || c == '\'' || c == '`': + inStr = true + quote = c + b.WriteByte(c) + case c == '/' && j+1 < len(line) && line[j+1] == '/': + break scan // rest of the line is a comment + case c == '/' && j+1 < len(line) && line[j+1] == '*': + inBlock = true + j++ + b.WriteByte(' ') + default: + b.WriteByte(c) + } + } + out[i] = b.String() + } + return out +} + func stripStringsAndComments(line string) string { var b strings.Builder b.Grow(len(line)) diff --git a/internal/codescan/scanner.go b/internal/codescan/scanner.go index e12b4f3..dc3bc43 100644 --- a/internal/codescan/scanner.go +++ b/internal/codescan/scanner.go @@ -159,6 +159,7 @@ func (s *Scanner) collectFiles() ([]FileContext, error) { "build": true, "dist": true, ".expo": true, "DerivedData": true, ".next": true, "vendor": true, } + unitySkip := UnityGeneratedDirs(s.root) err := filepath.Walk(s.root, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -166,7 +167,7 @@ func (s *Scanner) collectFiles() ([]FileContext, error) { } if info.IsDir() { - if skipDirs[info.Name()] { + if skipDirs[info.Name()] || unitySkip[path] { return filepath.SkipDir } return nil @@ -210,6 +211,8 @@ func detectLanguage(path string) string { return "swift" case ".m", ".h", ".mm": return "objc" + case ".cs": + return "csharp" case ".ts", ".tsx": return "typescript" case ".js", ".jsx": diff --git a/internal/codescan/unity.go b/internal/codescan/unity.go new file mode 100644 index 0000000..7c57b6c --- /dev/null +++ b/internal/codescan/unity.go @@ -0,0 +1,42 @@ +package codescan + +import ( + "os" + "path/filepath" +) + +// unityGeneratedDirNames are directories the Unity editor (re)generates on every +// import or build. They hold engine caches and IL artifacts — never shippable +// source — and in a mature project they dwarf Assets/ by an order of magnitude, +// so walking them wrecks scan time and drowns findings in vendored engine code. +var unityGeneratedDirNames = []string{ + "Library", "Temp", "Logs", "obj", "UserSettings", +} + +// UnityGeneratedDirs returns the full paths of Unity-generated directories +// directly under root, or nil when root is not a Unity project. +// +// Paths, not names: the editor only generates these as siblings of Assets/, so +// matching by basename at any depth would also skip real game source in folders +// like Assets/Scripts/Logs or a plugin's own Library/ — a silent blind spot in +// exactly the tree we are here to scan. +// +// Detection keys off ProjectSettings/ProjectSettings.asset, which every Unity +// project has and nothing else does; a directory named "Library" is perfectly +// normal elsewhere, so the skip list must never apply outside Unity. +func UnityGeneratedDirs(root string) map[string]bool { + if !IsUnityProject(root) { + return nil + } + dirs := make(map[string]bool, len(unityGeneratedDirNames)) + for _, d := range unityGeneratedDirNames { + dirs[filepath.Join(root, d)] = true + } + return dirs +} + +// IsUnityProject reports whether root is a Unity project directory. +func IsUnityProject(root string) bool { + _, err := os.Stat(filepath.Join(root, "ProjectSettings", "ProjectSettings.asset")) + return err == nil +} diff --git a/internal/privacy/csharp_unity_test.go b/internal/privacy/csharp_unity_test.go new file mode 100644 index 0000000..b0faf66 --- /dev/null +++ b/internal/privacy/csharp_unity_test.go @@ -0,0 +1,42 @@ +package privacy + +import ( + "testing" +) + +// Unity's PlayerPrefs is backed by NSUserDefaults on Apple platforms, so C# +// PlayerPrefs usage must surface as a UserDefaults required-reason API hit. +func TestPlayerPrefsIsUserDefaults(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "Save.cs", "public static void SaveLevel(int lv) {\n PlayerPrefs.SetInt(\"level\", lv);\n PlayerPrefs.Save();\n}\n") + + res, err := Scan(dir) + if err != nil { + t.Fatalf("Scan: %v", err) + } + for _, api := range res.DetectedAPIs { + if api == "User Defaults" { + return + } + } + t.Errorf("PlayerPrefs should be detected as User Defaults; DetectedAPIs=%v", res.DetectedAPIs) +} + +// Unity's ATT binding (ATTrackingStatusBinding) and a build post-processor that +// injects NSUserTrackingUsageDescription both count as ATT implementations, so +// a tracking SDK alongside either must not report missing ATT. +func TestUnityATTRecognized(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "Analytics.cs", "using AppsFlyerSDK;\nclass A { void S() { AppsFlyer.startSDK(); } }\n") + writeFile(t, dir, "PostBuild.cs", "plist.root.SetString(\"NSUserTrackingUsageDescription\", desc);\n") + + res, err := Scan(dir) + if err != nil { + t.Fatalf("Scan: %v", err) + } + for _, f := range res.Findings { + if f.Guideline == "5.1.2" { + t.Errorf("ATT handled via post-build injection; should not flag missing ATT: %+v", f) + } + } +} diff --git a/internal/privacy/scanner.go b/internal/privacy/scanner.go index 0fc7744..b3bf225 100644 --- a/internal/privacy/scanner.go +++ b/internal/privacy/scanner.go @@ -7,6 +7,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/RevylAI/greenlight/internal/codescan" ) // Finding from privacy scan. @@ -47,8 +49,11 @@ var requiredReasonAPIs = []RequiredReasonAPI{ regexp.MustCompile(`(?i)(\.creationDate|\.modificationDate|\.contentModificationDate|fileModificationDate|URLResourceKey\.contentModification)`), regexp.MustCompile(`(?i)(NSFileCreationDate|NSFileModificationDate)`), regexp.MustCompile(`(?i)(\bstat\s*\(|\bfstat\s*\(|\blstat\s*\(|getattrlist)`), + // .NET file metadata APIs hit the same underlying syscalls in + // Unity/Xamarin iOS builds, so they carry the same declaration duty. + regexp.MustCompile(`(File|FileInfo|Directory|FileSystemInfo)\s*\.\s*\w*(CreationTime|LastWriteTime|LastAccessTime)`), }, - Languages: []string{"swift", "objc", "typescript", "javascript"}, + Languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, Description: "Accessing file timestamps (creation date, modification date)", }, { @@ -87,9 +92,12 @@ var requiredReasonAPIs = []RequiredReasonAPI{ Patterns: []*regexp.Regexp{ regexp.MustCompile(`(?i)(UserDefaults|NSUserDefaults)`), regexp.MustCompile(`(?i)(AsyncStorage|@react-native-async-storage)`), + // Unity's PlayerPrefs is backed by NSUserDefaults on Apple platforms, + // so every PlayerPrefs call is a UserDefaults access in the shipped app. + regexp.MustCompile(`\bPlayerPrefs\s*\.`), }, - Languages: []string{"swift", "objc", "typescript", "javascript"}, - Description: "Reading/writing UserDefaults (includes React Native AsyncStorage)", + Languages: []string{"swift", "objc", "typescript", "javascript", "csharp"}, + Description: "Reading/writing UserDefaults (includes React Native AsyncStorage and Unity PlayerPrefs)", }, } @@ -145,10 +153,11 @@ func Scan(projectPath string) (*ScanResult, error) { "build": true, "dist": true, ".expo": true, "DerivedData": true, "vendor": true, } + unitySkip := codescan.UnityGeneratedDirs(projectPath) filepath.Walk(projectPath, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { - if info != nil && info.IsDir() && skipDirs[info.Name()] { + if info != nil && info.IsDir() && (skipDirs[info.Name()] || unitySkip[path]) { return filepath.SkipDir } return nil @@ -167,8 +176,10 @@ func Scan(projectPath string) (*ScanResult, error) { fullContent := strings.Join(lines, "\n") - // Check for ATT implementation - if regexp.MustCompile(`(?i)(ATTrackingManager|requestTrackingAuthorization|AppTrackingTransparency|expo-tracking-transparency)`).MatchString(fullContent) { + // Check for ATT implementation. ATTrackingStatusBinding is Unity's ATT + // binding; NSUserTrackingUsageDescription in source means a build + // post-processor injects the ATT purpose string into Info.plist. + if regexp.MustCompile(`(?i)(ATTrackingManager|requestTrackingAuthorization|AppTrackingTransparency|expo-tracking-transparency|ATTrackingStatusBinding|NSUserTrackingUsageDescription)`).MatchString(fullContent) { hasATT = true } @@ -342,6 +353,8 @@ func detectLang(path string) string { return "swift" case ".m", ".h": return "objc" + case ".cs": + return "csharp" case ".ts", ".tsx": return "typescript" case ".js", ".jsx":