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:
parent
9ba97b7e29
commit
61cb3338e8
13 changed files with 258 additions and 6 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue