scan: remote config, feature flags and remote paywalls not told to review (App Review 2.3.1, Play Deceptive behavior): remote-features finding for both stores, medium, with deep links, unless the review notes mention them; check prints it as a warning, validate carries it; warn only, the templates get a commented example line; code obfuscators a low note, minify alone nothing; store-pitfalls 74

This commit is contained in:
CRTkafa 2026-10-18 01:26:32 +08:00
parent 9ba97b7e29
commit 61cb3338e8
13 changed files with 258 additions and 6 deletions

View file

@ -44,7 +44,8 @@ import { itms91064, LISTED_SDKS, REQUIRED_REASON_APIS, trackingIssue, trackingPr
import { IOS_PERMISSIONS, vaguePurpose } from './permissions';
import { playRules } from './play-rules';
import { grep, grepOwn, type Hit, lineOf, type ProjectFacts } from './project';
import { versionAtLeast } from './sdks';
import { matchesSdk, OBFUSCATORS, REMOTE_CODE, REMOTE_SDKS, type RemoteSdkDef, versionAtLeast } from './sdks';
import { reviewNotes } from '../review-details';
export type Severity = 'blocker' | 'high' | 'medium' | 'low';
export type ScanStore = 'apple' | 'play';
@ -199,6 +200,54 @@ function listingHits(file: { path: string; text: string } | undefined, re: RegEx
return out;
}
/** Review notes that already tell App Review about remote config, flags, experiments or remotely changed paywalls. */
export const REMOTE_NOTE = /remote[ -]?config|feature[ -]?flags?|\bA\/B\b|paywall (tests?|experiments?|designs?)|experiments?\b|remotely/i;
/**
* App Review 2.3.1 (hidden, dormant or undocumented features) and Play Deceptive Behavior: remote config / feature
* flag SDKs and remote paywalls in the dependencies or own sources, unless the review notes (apple.info.reviewNotes,
* reviewAccess.instructions, which also go to Play App access, accounts.inApp) mention them. Warns only: storebound
* never writes the note; whether and how to document it is the developer's call. Code obfuscators get a low note
* (minify / R8 alone never). Pure.
*/
export function remoteFeatureFindings(p: Pick<ProjectFacts, 'deps' | 'sources' | 'builtDirs'>, c: ShipConfig | null, stores: { apple: boolean; play: boolean }): ScanFinding[] {
const out: ScanFinding[] = [];
const found = new Map<string, { name: string; kind: RemoteSdkDef['kind']; hits: Hit[] }>();
const put = (id: string, name: string, kind: RemoteSdkDef['kind'], hits: Hit[]) => {
if (!hits.length) return;
const e = found.get(id) ?? { name, kind, hits: [] };
e.hits.push(...hits);
found.set(id, e);
};
for (const s of REMOTE_SDKS) {
const deps = p.deps.filter(d => matchesSdk(d, s));
if (!deps.length) continue;
if (s.needsCode && !grepOwn(p, codeLine(s.needsCode), 1).hits.length) continue;
put(s.id, s.name, s.kind, deps.slice(0, 2).map(d => ({ file: d.file, line: d.line, text: `${s.name}: ${d.name}${d.version ? ` ${d.version}` : ''}` })));
}
for (const s of REMOTE_CODE) if (!found.has(s.id)) put(s.id, s.name, s.kind, grepOwn(p, codeLine(s.re), 1).hits);
if (found.size && !REMOTE_NOTE.test(c ? reviewNotes(c) : '')) {
const list = [...found.values()];
const names = list.map(x => x.name).join(', ');
const paywall = list.some(x => x.kind === 'paywall');
const what = paywall && list.some(x => x.kind === 'remote-config') ? 'remote config / feature flags and remote paywalls' : paywall ? 'remote paywalls' : 'remote config / feature flags';
const evidence = list.flatMap(x => x.hits.slice(0, 1)).slice(0, 4);
const fix = (where: string) => `Say in the review notes that paywall designs / prices shown / these flags change remotely (${names}), and document any feature that can be switched on or off remotely; a change of business model (soft -> hard paywall, features moved behind a subscription) needs a new review and a description update (paid functionality must be disclosed). Paywall tests and designs alone are fine once App Review knows. ${where}`;
if (stores.apple)
out.push({ rule: 'remote-features', guideline: '2.3.1', store: 'apple', severity: 'medium', title: `${what} (${names}) not mentioned in the App Review notes`, evidence,
fix: fix(`Notes: apple.info.reviewNotes, sent with storebound apple review-details; ${consoleLink('asc.version', '> App Review Information')}.`), url: consoleUrl('asc.version'), skill: 'review-rejections', ref: APPLE_GUIDELINES });
if (stores.play)
out.push({ rule: 'remote-features', guideline: 'Play: Deceptive behavior', store: 'play', severity: 'medium', title: `${what} (${names}) not mentioned to Play review`, evidence,
fix: fix(`Play: reviewAccess.instructions go to ${consoleLink('play.app-access')} ("Any other information"), and the listing description must match what the app does.`), url: consoleUrl('play.app-access'), skill: 'review-rejections', ref: PLAY_POLICY });
}
const obf = OBFUSCATORS.flatMap(o => p.deps.filter(d => matchesSdk(d, o)).slice(0, 1).map(d => ({ file: d.file, line: d.line, text: `${o.name}: ${d.name}` })));
if (obf.length) for (const store of (['apple', 'play'] as const).filter(s => stores[s]))
out.push({ rule: 'obfuscation', guideline: store === 'apple' ? '2.3.1' : 'Play: Deceptive behavior', store, severity: 'low', title: 'Code obfuscator in the build: fine for protecting code, never for hiding features from review', evidence: obf.slice(0, 3),
fix: 'Note only: obfuscating code is allowed, hiding behaviour with it is not (Apple 2.3.1 names obfuscation as grounds for removal). Make sure every feature the app has is visible to the reviewer and in the review notes. ProGuard / R8 minify alone is normal.',
skill: 'review-rejections', ref: store === 'apple' ? APPLE_GUIDELINES : PLAY_POLICY });
return out;
}
export function scanRules(inp: ScanInput): ScanFinding[] {
const { facts: p, config: c } = inp;
const out: ScanFinding[] = [];
@ -587,6 +636,9 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
fix: 'Pick your own reverse-domain package name (it can never change after the first upload) and set it in the Gradle applicationId and play.packageName.', skill: 'play-first-release',
ref: 'https://developer.android.com/build/configure-app-module' });
// ---- remotely switchable features, remote paywalls, obfuscation (2.3.1, Play Deceptive behavior) ----
out.push(...remoteFeatureFindings(p, c, { apple, play }));
// ---- the other Google Play policies (play-rules.ts) ----
if (play)
playRules(inp, { add: f => add({ ...f, store: 'play' }), cfg, sdkHits, listingHits, creates, restorable, paySdk });

View file

@ -181,11 +181,80 @@ export const SDKS: SdkDef[] = [
match: { npm: ['@capacitor/push-notifications', 'expo-notifications', '@react-native-community/push-notification-ios', 'react-native-push-notification'], pub: ['flutter_local_notifications'], unity: ['com.unity.mobile.notifications'] } },
];
// ---- remotely switchable features and remote paywalls (App Review 2.3.1, Play Deceptive Behavior) ----
// Not in SDKS: they add no data types of their own to the declarations; scan only asks that App Review hears about
// them. Checked 2026-10-18 against each vendor's install docs.
export interface RemoteSdkDef {
id: string;
name: string;
/** remote-config: feature flags / remote config / experiments; paywall: paywall designs, offers or prices set remotely. */
kind: 'remote-config' | 'paywall';
match: Partial<Record<Eco, string[]>>;
/** Only with this in the own sources (an SDK that also does other things, e.g. PostHog analytics). */
needsCode?: RegExp;
docs: string;
}
export const REMOTE_SDKS: RemoteSdkDef[] = [
{ id: 'firebase-remote-config', name: 'Firebase Remote Config', kind: 'remote-config', docs: 'https://firebase.google.com/docs/remote-config',
match: { cocoapods: ['FirebaseRemoteConfig', 'Firebase/RemoteConfig'], spm: ['firebase-ios-sdk#FirebaseRemoteConfig'], gradle: ['com.google.firebase:firebase-config', 'com.google.firebase:firebase-config-ktx'],
npm: ['@react-native-firebase/remote-config', '@capacitor-firebase/remote-config'], pub: ['firebase_remote_config'], unity: ['com.google.firebase.remote-config', 'FirebaseRemoteConfig'] } },
{ id: 'statsig', name: 'Statsig', kind: 'remote-config', docs: 'https://docs.statsig.com/',
match: { cocoapods: ['Statsig'], spm: ['statsig-kit'], gradle: ['com.statsig:android-sdk'], npm: ['statsig-react-native', 'statsig-js', '@statsig/js-client', '@statsig/react-native-bindings', '@statsig/expo-bindings'], pub: ['statsig'] } },
{ id: 'launchdarkly', name: 'LaunchDarkly', kind: 'remote-config', docs: 'https://docs.launchdarkly.com/sdk',
match: { cocoapods: ['LaunchDarkly'], spm: ['launchdarkly/ios-client-sdk'], gradle: ['com.launchdarkly:launchdarkly-android-client-sdk'],
npm: ['@launchdarkly/react-native-client-sdk', 'launchdarkly-react-native-client-sdk', 'launchdarkly-js-client-sdk', '@launchdarkly/js-client-sdk'], pub: ['launchdarkly_flutter_client_sdk'] } },
{ id: 'growthbook', name: 'GrowthBook', kind: 'remote-config', docs: 'https://docs.growthbook.io/',
match: { cocoapods: ['GrowthBook-IOS'], spm: ['growthbook-swift'], gradle: ['io.growthbook.sdk:GrowthBook'], npm: ['@growthbook/growthbook', '@growthbook/growthbook-react'], pub: ['growthbook_sdk_flutter'] } },
{ id: 'optimizely', name: 'Optimizely', kind: 'remote-config', docs: 'https://docs.developers.optimizely.com/feature-experimentation/docs',
match: { cocoapods: ['OptimizelySwiftSDK'], spm: ['optimizely/swift-sdk'], gradle: ['com.optimizely.ab:android-sdk'], npm: ['@optimizely/react-sdk', '@optimizely/optimizely-sdk', '@optimizely/react-native-sdk'], pub: ['optimizely_flutter_sdk'] } },
{ id: 'posthog-flags', name: 'PostHog feature flags', kind: 'remote-config', docs: 'https://posthog.com/docs/feature-flags',
needsCode: /\b(isFeatureEnabled|getFeatureFlag|getFeatureFlagPayload|useFeatureFlag|useFeatureFlagEnabled|onFeatureFlags|reloadFeatureFlags)\b/,
match: { cocoapods: ['PostHog'], spm: ['posthog-ios'], gradle: ['com.posthog:posthog-android', 'com.posthog.android:posthog'], npm: ['posthog-react-native', 'posthog-js'], pub: ['posthog_flutter'] } },
{ id: 'unleash', name: 'Unleash', kind: 'remote-config', docs: 'https://docs.getunleash.io/',
match: { cocoapods: ['UnleashProxyClientSwift'], spm: ['unleash-proxy-client-swift'], gradle: ['io.getunleash:unleash-android'], npm: ['unleash-proxy-client', '@unleash/proxy-client-react'], pub: ['unleash_proxy_client_flutter'] } },
{ id: 'configcat', name: 'ConfigCat', kind: 'remote-config', docs: 'https://configcat.com/docs/',
match: { cocoapods: ['ConfigCat'], spm: ['configcat/swift-sdk'], gradle: ['com.configcat:configcat-android-client'], npm: ['configcat-js', 'configcat-react', 'configcat-js-ssr', '@configcat/sdk'], pub: ['configcat_client'] } },
{ id: 'amplitude-experiment', name: 'Amplitude Experiment', kind: 'remote-config', docs: 'https://amplitude.com/docs/feature-experiment',
match: { cocoapods: ['AmplitudeExperiment'], spm: ['experiment-ios-client'], gradle: ['com.amplitude:experiment-android-client'], npm: ['@amplitude/experiment-js-client', '@amplitude/experiment-react-native-client'] } },
{ id: 'split', name: 'Split', kind: 'remote-config', docs: 'https://help.split.io/',
match: { cocoapods: ['Split'], spm: ['splitio/ios-client'], gradle: ['io.split.client:android-client'], npm: ['@splitsoftware/splitio', '@splitsoftware/splitio-react-native', '@splitsoftware/splitio-react'], pub: ['splitio'] } },
// paywalls whose design, products or prices are set from a dashboard
{ id: 'revenuecat-paywalls', name: 'RevenueCat Paywalls', kind: 'paywall', docs: 'https://www.revenuecat.com/docs/tools/paywalls',
match: { cocoapods: ['RevenueCatUI'], spm: ['purchases-ios#RevenueCatUI'], gradle: ['com.revenuecat.purchases:purchases-ui'], npm: ['react-native-purchases-ui', '@revenuecat/purchases-capacitor-ui'], pub: ['purchases_ui_flutter'] } },
{ id: 'superwall', name: 'Superwall', kind: 'paywall', docs: 'https://superwall.com/docs',
match: { cocoapods: ['SuperwallKit'], spm: ['Superwall-iOS'], gradle: ['com.superwall.sdk:superwall-android'], npm: ['@superwall/react-native-superwall', 'expo-superwall'], pub: ['superwallkit_flutter'] } },
{ id: 'adapty', name: 'Adapty (paywall builder)', kind: 'paywall', docs: 'https://adapty.io/docs/',
match: { cocoapods: ['Adapty', 'AdaptyUI'], spm: ['AdaptySDK-iOS'], gradle: ['io.adapty:android-sdk', 'io.adapty:android-ui'], npm: ['react-native-adapty', '@adapty/capacitor', '@adapty/react-native-ui'], pub: ['adapty_flutter', 'adapty_ui_flutter'], unity: ['com.adapty.unity', 'AdaptySDK'] } },
{ id: 'qonversion', name: 'Qonversion', kind: 'paywall', docs: 'https://documentation.qonversion.io/',
match: { cocoapods: ['Qonversion'], spm: ['qonversion-ios-sdk'], gradle: ['io.qonversion.android.sdk:sdk', 'io.qonversion:sdk'], npm: ['react-native-qonversion', '@qonversion/capacitor-plugin'], pub: ['qonversion_flutter'] } },
{ id: 'purchasely', name: 'Purchasely', kind: 'paywall', docs: 'https://docs.purchasely.com/',
match: { cocoapods: ['Purchasely'], spm: ['Purchasely-iOS'], gradle: ['io.purchasely:core'], npm: ['react-native-purchasely', '@purchasely/cordova-plugin-purchasely'], pub: ['purchasely_flutter'] } },
{ id: 'apphud', name: 'Apphud', kind: 'paywall', docs: 'https://docs.apphud.com/',
match: { cocoapods: ['ApphudSDK'], spm: ['ApphudSDK'], gradle: ['com.apphud:ApphudSDK-Android'], npm: ['@apphud/react-native-apphud-sdk', 'react-native-apphud-sdk'], pub: ['apphud'] } },
];
/** Remote config / paywall use in the own sources without a listed dependency (the web Firebase SDK, a RevenueCat
* paywall presented from the core package). Line-anchored code, never an import line. */
export const REMOTE_CODE: { id: string; name: string; kind: RemoteSdkDef['kind']; re: RegExp }[] = [
{ id: 'firebase-remote-config', name: 'Firebase Remote Config', kind: 'remote-config', re: /\bgetRemoteConfig\(|\bfetchAndActivate\(|\bremoteConfig\(\)\.|RemoteConfig\.remoteConfig\(\)|FirebaseRemoteConfig\.(getInstance|instance)\b|\bFirebase\.RemoteConfig\b/ },
{ id: 'revenuecat-paywalls', name: 'RevenueCat Paywalls', kind: 'paywall', re: /\bpresentPaywall(IfNeeded)?\b|\bRevenueCatUI\b|\bpaywallFooter\(|\bPaywallView\(/ },
];
/** Code obfuscators that hide what the app does (not plain minify: ProGuard / R8 shrinking is normal on Android). */
export const OBFUSCATORS: { name: string; match: Partial<Record<Eco, string[]>> }[] = [
{ name: 'DexGuard', match: { gradle: ['com.guardsquare.dexguard', 'com.guardsquare:dexguard-gradle-plugin', 'dexguard'] } },
{ name: 'iXGuard', match: { cocoapods: ['iXGuard'] } },
{ name: 'JavaScript obfuscator', match: { npm: ['javascript-obfuscator', 'react-native-obfuscating-transformer', 'obfuscator-io-metro-plugin', 'webpack-obfuscator', 'vite-plugin-javascript-obfuscator', 'rollup-plugin-obfuscator'] } },
{ name: 'Beebyte Obfuscator', match: { unity: ['Beebyte'] } },
{ name: 'SwiftShield', match: { cocoapods: ['SwiftShield'], spm: ['swiftshield'] } },
];
export interface DepRef { eco: Eco; name: string; version: string | null; file: string; line: number }
/** Does a dependency match an SDK? Pods match by name or "Name/Sub"; Gradle by "group:artifact" or a "group:" prefix;
* SPM by URL fragment (or "url#Product" for one product of a multi-product repository, matched on the product). */
export function matchesSdk(d: DepRef, s: SdkDef): boolean {
export function matchesSdk(d: DepRef, s: Pick<SdkDef, 'match'>): boolean {
const list = s.match[d.eco] ?? [];
const n = d.name;
return list.some(m => {