597 lines
60 KiB
TypeScript
597 lines
60 KiB
TypeScript
// App Review and Google Play rejection-risk rules for `storebound scan`. Pure: they read the detected project facts,
|
|
// the config (optional) and the listing files, and return findings with the guideline, the evidence (file:line), the
|
|
// fix, a severity and the skill that covers it. Only what files or the config prove is reported; where a rule cannot
|
|
// see enough (a sign-in flow behind a custom API, a restore button under another name) its fix says what to check.
|
|
//
|
|
// Sources, checked 2026-10-11:
|
|
// - Apple App Review Guidelines: https://developer.apple.com/app-store/review/guidelines/
|
|
// 2.1(a) app completeness (demo account, placeholder text), 2.2 betas, demos and trial versions (TestFlight), 2.3.7 metadata (names, keywords, no prices or other
|
|
// apps' names), 2.3.8 ("For Kids" is reserved for the Kids Category), 2.3.10 other mobile platforms, 2.5.1 public
|
|
// APIs, 2.5.5 IPv6-only networks, 3.1.1 restore mechanism, 3.1.2(c) subscription information (+ Schedule 2 of the
|
|
// Developer Program License Agreement: Terms of Use and privacy policy links), 4.2 minimum functionality
|
|
// (repackaged websites), 4.8 Login Services (an equivalent login option), 5.1.1(ii) purpose strings, 5.1.1(v)
|
|
// account deletion in the app, 5.1.2(i) App Tracking Transparency, 1.3 Kids Category (no third-party ads/analytics),
|
|
// 5.1.1(i) privacy policy link inside the app, 5.1.1(iv) only the access a feature needs, 5.1.2 privacy labels that
|
|
// match the SDKs, 3.1.1 calls to action for purchases outside in-app purchase (3.1.3(e): physical goods are fine),
|
|
// 3.1.2 subscription management, 2.5.1 private settings URL schemes, 5.6.1 system review prompt only, 3.2.2(x) and
|
|
// 5.6.3 no rewards for ratings.
|
|
// - The list of common rejections the rules cover was cross-checked with community collections (paraphrased, each item
|
|
// verified against Apple's guidelines above): github.com/rshankras/claude-code-apple-skills (app-store /
|
|
// rejection-handler / common-rejections.md), github.com/rorkai/app-store-connect-cli-skills (asc-submission-health)
|
|
// and ascskill.wiki. Items that only show at review time (crashes, dead links, misleading screenshots, poor UI, 4.3
|
|
// spam, 5.2 local law) are not file-detectable: the review-rejections skill lists them as "checked at review".
|
|
// - Apple, upload checks: ITMS-90683 missing purpose string, ITMS-90809 UIWebView, ITMS-91053 / 91061 privacy
|
|
// manifest (developer.apple.com/documentation/bundleresources/privacy-manifest-files), App Transport Security
|
|
// (developer.apple.com/documentation/security/preventing-insecure-network-connections), export compliance
|
|
// (developer.apple.com/documentation/security/complying-with-encryption-export-regulations).
|
|
// - Google Play: target API level (developer.android.com/google/play/requirements/target-sdk: from 2026-08-31 new apps
|
|
// and updates target API 36, Wear OS / Android Automotive 35, Android TV / XR 34; extension to 2026-11-01 on request),
|
|
// account deletion (support.google.com/googleplay/android-developer/answer/13327111), foreground service types
|
|
// (answer/13392821), Permissions and APIs that Access Sensitive Information (answer/9888170: exact alarm,
|
|
// QUERY_ALL_PACKAGES, SMS and Call Log), App access for reviewers (answer/9859455), Families policy (answer/9893335),
|
|
// Metadata (answer/9898842), Data safety (answer/10787469). The reserved com.example package name is an upload
|
|
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
|
|
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
|
|
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
|
|
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
|
|
import type { ShipConfig } from '../config';
|
|
import { allSubscriptions } from '../subscriptions';
|
|
import { sdkOfProjectPath } from '../privacy-files';
|
|
import { itms91064, LISTED_SDKS, REQUIRED_REASON_APIS, trackingIssue, trackingProblem } from '../privacy-manifest';
|
|
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';
|
|
|
|
export type Severity = 'blocker' | 'high' | 'medium' | 'low';
|
|
export type ScanStore = 'apple' | 'play';
|
|
|
|
export interface ScanFinding {
|
|
/** Stable rule id (config `scan.ignore` uses it). */
|
|
rule: string;
|
|
/** Guideline or policy, e.g. "5.1.1(v)" or "Play: Target API level". */
|
|
guideline: string;
|
|
store: ScanStore;
|
|
severity: Severity;
|
|
title: string;
|
|
evidence: Hit[];
|
|
fix: string;
|
|
skill: string;
|
|
/** Policy page. */
|
|
ref: string;
|
|
/** Owner accepted it (`scan.ignore` in the config) with this reason. */
|
|
accepted?: string;
|
|
}
|
|
|
|
export interface ScanInput {
|
|
facts: ProjectFacts;
|
|
config: ShipConfig | null;
|
|
/** The config file as text (relative path), for line numbers. */
|
|
configFile: { path: string; text: string } | null;
|
|
/** iOS metadata / Play listing / screenshot captions as text (relative paths). */
|
|
listings: { apple?: { path: string; text: string }; play?: { path: string; text: string }; captions?: { path: string; text: string } };
|
|
/** A demo account for App Review is stored locally (.storebound/credentials.json or REVIEW_DEMO_*). */
|
|
demoAccount: boolean;
|
|
/** Data types the detected SDKs collect that the config's dataSafety does not declare (from the declarations
|
|
* proposal): privacy label mismatch. */
|
|
labelGaps?: { type: string; apple: string; evidence: Hit[] }[];
|
|
/** Data types the config's dataSafety declares, but with fewer purposes or less sharing than detected (Play). */
|
|
dataMerges?: { type: string; missing: string; evidence: Hit[] }[];
|
|
}
|
|
|
|
export const APPLE_GUIDELINES = 'https://developer.apple.com/app-store/review/guidelines/';
|
|
const PLAY = 'https://support.google.com/googleplay/android-developer/answer/';
|
|
export const PLAY_POLICY = 'https://play.google/developer-content-policy/';
|
|
export const PLAY_TARGET_SDK = { phone: 36, wear: 35, automotive: 35, tv: 34 } as const;
|
|
export const PLAY_TARGET_SDK_SINCE = '2026-08-31';
|
|
|
|
// ---- code signals ----
|
|
/** `re` only on lines that are not import / require / export-from statements (a module path is not evidence). Line
|
|
* anchored with the m flag, so a whole-file test and a per-line test agree. */
|
|
export function codeLine(re: RegExp): RegExp {
|
|
return new RegExp(`^(?!\\s*import[\\s{*'"])(?!.*\\b(?:require|import)\\s*\\(\\s*['"\`])(?!.*\\bfrom\\s*['"\`]).*?(?:${re.source})`, `${re.flags.replace('m', '')}m`);
|
|
}
|
|
export const SIGNALS = {
|
|
appleSignIn: /ASAuthorizationAppleIDProvider|SignInWithAppleButton|AppleAuthenticationButton|AppleAuthentication\.signInAsync|appleAuth\.performRequest|SignInWithApple|OAuthProvider\(\s*['"]apple\.com|AppleAuthProvider|signInWithApple|AppleAuthManager/,
|
|
thirdPartyLogin: /GIDSignIn|GoogleSignin\.signIn|GoogleSignIn\(|GoogleAuthProvider|signInWithGoogle|FacebookAuthProvider|LoginManager\(\)\.logIn|LoginManager\.logInWithPermissions|signInWithFacebook|FacebookLogin|TwitterAuthProvider|GithubAuthProvider|OAuthProvider\(\s*['"](microsoft\.com|yahoo\.com|twitter\.com|github\.com)/,
|
|
// sign-in / sign-up: auth SDK calls, credential fields, guest-account creation, auth endpoints (an absolute path, a
|
|
// URL or a `${base}/...` template, never a module path like './buildings/register') and screen strings. Import /
|
|
// require lines never count: a module named register, login or account is not an auth flow.
|
|
signIn: codeLine(/signInWithEmailAndPassword|signInWithPassword|signInWithCredential|createUserWithEmailAndPassword|\bsupabase\.auth\.sign|Amplify\.Auth\.signIn|\bAuth0\b|\bloginWithPassword\b|\.signIn\(\s*\{|autoComplete=["']current-password|textContentType=["']?\.?password\b|\btype=["']password["']|['"`](https?:\/\/[^'"`\s]*|\$\{[^}]*\})?\/(api\/|v\d+\/)*(auth\/|users?\/|accounts?\/)?(login|signin|sign-in)['"`?]/),
|
|
signUp: codeLine(/createUserWithEmailAndPassword|\bsignUp\s*\(|\bsupabase\.auth\.signUp|Amplify\.Auth\.signUp|autoComplete=["']new-password|textContentType=["']?\.?newPassword\b|\bcreate(Guest|Anonymous)(Account|User)\b|\bregisterGuest\b|['"`](https?:\/\/[^'"`\s]*|\$\{[^}]*\})?\/(api\/|v\d+\/)*(auth\/|users?\/|accounts?\/)?(register|signup|sign-up)['"`?]|["'>](Create (an )?account|Sign up)["'<]/),
|
|
// real evidence that the app collects an email address: an email input or keyboard, an `email` property in a request
|
|
// body / schema / type (not a quoted constant or label), an email validation, an email endpoint or an email sign-in
|
|
// call. Accounts alone never mean email (a username with a device token has none).
|
|
email: /type\s*[:=]\s*["'{]*email["'}]|inputMode=["']email["']|keyboardType=["']?email-address|autoComplete=["']email|textContentType=["']?(emailAddress|\.emailAddress)|UITextContentType\.emailAddress|\.keyboardType\(\.emailAddress\)|keyboardType\s*=\s*\.emailAddress|TextInputType\.emailAddress|TYPE_TEXT_VARIATION_EMAIL_ADDRESS|KeyboardType\.Email\b|(?<![\w.$-])["']?email["']?\s*\??:\s*(?![\s"'`]|$)|z\.string\(\)(\.\w+\([^)]*\))*\.email\(|\bisEmail\(|\[\^\\s@\]\+@|\[a-zA-Z0-9._%+-\]\+@|\/(api\/)?(auth\/)?(email|verify-email|forgot-password|reset-password|magic-link)\b['"`]|signInWithEmailAndPassword|createUserWithEmailAndPassword|sendSignInLinkToEmail|createUser\(withEmail:|signIn\(withEmail:|sendSignInLink\(toEmail:|signInWithOtp\(\s*\{\s*email|ASAuthorization\.Scope\.email|requestedScopes[^\n]{0,40}\.email\b/,
|
|
deleteAccount: codeLine(/deleteUser\b|\buser\.delete\(|currentUser\??\.delete\(|\bdeleteAccount\b|delete_account|DeleteAccount|delete-account|["'>]Delete (my |your )?account|\brevokeToken\b|auth\.admin\.deleteUser|Hesab(ı|ımı) sil/i),
|
|
restore: /restoreCompletedTransactions|AppStore\.sync\(|restorePurchases|restoreTransactions|RestoreTransactions|getAvailablePurchases|\brestorePurchase\b|\.restore\(\)|Restore Purchases|syncPurchases|Purchases\.shared\.restore|store\.restore\b|queryPurchasesAsync/i,
|
|
subscriptionCode: /subscriptionPeriod|Product\.SubscriptionInfo|\.autoRenewable\b|entitlements\.active|SubscriptionStoreView|ProductType\.Subscription|ProductType\.SUBS|BillingClient\.ProductType\.SUBS/,
|
|
attRequest: /requestTrackingAuthorization|requestTrackingPermissionsAsync|requestTrackingPermission\b|AppTrackingTransparency\.requestTrackingAuthorization|ATTrackingStatusBinding\.RequestAuthorizationTracking|TrackingTransparency\.requestPermission/,
|
|
idfa: /\bASIdentifierManager\b|advertisingIdentifier\b/,
|
|
uiWebView: /\bUIWebView\b/,
|
|
wkRemote: /\.load\(\s*URLRequest\(\s*url:\s*URL\(\s*string:\s*"https?:\/\/(?!localhost|127\.0\.0\.1)/,
|
|
ipv4: /["'`]https?:\/\/(?!127\.0\.0\.1|0\.0\.0\.0|10\.0\.2\.2)(\d{1,3}\.){3}\d{1,3}(?=[:/"'`])/,
|
|
inAppLegal: /terms|eula|privacy|nutzungsbedingungen|kullanım koşulları|gizlilik/i,
|
|
privacyLink: /privacy|gizlilik|datenschutz|confidentialit|privacidad/i,
|
|
manageSubs: /showManageSubscriptions|manageSubscriptionsSheet|apps\.apple\.com\/account\/subscriptions|managementURL|showManageSubscriptions|manageSubscriptions\b/,
|
|
privateScheme: /["'`](App-Prefs|prefs):root\b/,
|
|
ratePrompt: /["'`>](Rate (us|this app)|Enjoying [A-Z][\w ]{0,30}\?|Love (this|the) app\?)/,
|
|
reviewApi: /SKStoreReviewController|requestReview|StoreReview\.requestReview|InAppReview|in_app_review|react-native-rate|requestReviewFlow|ReviewManager/,
|
|
// a reward tied to rating: a "rate us / rate this app / leave a review" phrase next to a reward word, or a reward
|
|
// "for rating / reviewing". A bare "rate" or "review" is never enough (a production rate, frame rate, exchangeRate,
|
|
// rate limit or code review next to "bonus" is not a review prompt).
|
|
incentiveRating: /\b(rate|review) (us|me|this (app|game)|the (app|game)|our (app|game))\b[^\n]{0,60}?\b(rewards?|rewarded|coins?|gems?|bonus|free (lives|spins|coins))\b|\b(leave|write|give) (us |me )?(a )?(review|rating|(5|five)[- ]?stars?)\b[^\n]{0,60}?\b(rewards?|rewarded|coins?|gems?|bonus|free (lives|spins|coins))\b|\b(rewards?|coins?|gems?|bonus)\b[^"'`\n]{0,40}\b(for|to|by) (rating|reviewing|(rate|review) (us|me|the|this|our)\b|leaving a review|writing a review)/i,
|
|
// a reward granted next to a system review prompt call (checked within a few lines by rewardNearReviewApi)
|
|
reviewApiCall: /SKStoreReviewController\.requestReview|\brequestReview\s*\(|requestReviewFlow\s*\(|launchReviewFlow\s*\(|InAppReview\.(instance\.)?requestReview|StoreReview\.requestReview|\.requestReview\(\)/,
|
|
rewardGrant: /\b(add|give|grant|award)(Coins?|Gems?|Reward|Bonus|Currency|Lives)\b|\b(reward|bonus)\s*\(|\bcoins\s*\+=|\bgems\s*\+=|\bgrantReward\b/i,
|
|
externalBuy: /\b(buy|subscribe|purchase|upgrade|pay)\b[^.\n]{0,40}\b(on|at|via|from) (our )?(website|web ?site|web|site)\b/i,
|
|
};
|
|
|
|
const ATT_SDKS_SHOW_PROMPT = new Set(['applovin', 'levelplay']);
|
|
const PLACEHOLDER = { test: (l: string) => PLACEHOLDER_MARKERS.test(l) || PLACEHOLDER_TEXT.test(l) } as RegExp;
|
|
const FGS_PERMISSION: Record<string, string> = {
|
|
camera: 'CAMERA', connectedDevice: 'CONNECTED_DEVICE', dataSync: 'DATA_SYNC', health: 'HEALTH', location: 'LOCATION', mediaPlayback: 'MEDIA_PLAYBACK',
|
|
mediaProjection: 'MEDIA_PROJECTION', microphone: 'MICROPHONE', phoneCall: 'PHONE_CALL', remoteMessaging: 'REMOTE_MESSAGING', specialUse: 'SPECIAL_USE',
|
|
systemExempted: 'SYSTEM_EXEMPTED', mediaProcessing: 'MEDIA_PROCESSING',
|
|
};
|
|
const SMS_CALL_LOG = ['READ_SMS', 'RECEIVE_MMS', 'RECEIVE_SMS', 'RECEIVE_WAP_PUSH', 'SEND_SMS', 'WRITE_SMS', 'READ_CALL_LOG', 'WRITE_CALL_LOG', 'PROCESS_OUTGOING_CALLS'];
|
|
|
|
/** A system review prompt call with a reward grant within `win` lines of it (rewarding the rating): the call line. */
|
|
export function rewardNearReviewApi(sources: { path: string; text: string }[], max = 2, win = 6): Hit[] {
|
|
const out: Hit[] = [];
|
|
for (const s of sources) {
|
|
if (!SIGNALS.reviewApiCall.test(s.text) || !SIGNALS.rewardGrant.test(s.text)) continue;
|
|
const lines = s.text.split('\n');
|
|
lines.forEach((l, i) => {
|
|
if (out.length >= max || !SIGNALS.reviewApiCall.test(l) || /^\s*(\/\/|#|\*)/.test(l)) return;
|
|
if (lines.slice(Math.max(0, i - win), i + win + 1).some(x => SIGNALS.rewardGrant.test(x) && !/^\s*(\/\/|#|\*)/.test(x)))
|
|
out.push({ file: s.path, line: i + 1, text: l.trim().slice(0, 160) });
|
|
});
|
|
if (out.length >= max) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Text lines (inside fenced blocks of a listing file, or JSON string values) that match `re`. */
|
|
function listingHits(file: { path: string; text: string } | undefined, re: RegExp, o: { fenced?: boolean; field?: RegExp } = {}): Hit[] {
|
|
if (!file) return [];
|
|
const out: Hit[] = [];
|
|
let inFence = false, heading = '';
|
|
file.text.split('\n').forEach((l, i) => {
|
|
if (/^###\s/.test(l)) heading = l.slice(4).trim();
|
|
if (/^```/.test(l)) { inFence = !inFence; return; }
|
|
if (o.fenced !== false && !inFence) return;
|
|
if (o.field && !o.field.test(heading)) return;
|
|
if (re.test(l)) out.push({ file: file.path, line: i + 1, text: l.trim().slice(0, 160) });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
export function scanRules(inp: ScanInput): ScanFinding[] {
|
|
const { facts: p, config: c } = inp;
|
|
const out: ScanFinding[] = [];
|
|
const add = (f: Omit<ScanFinding, 'ref'> & { ref?: string }) => out.push({ ref: f.store === 'apple' ? APPLE_GUIDELINES : PLAY_POLICY, ...f });
|
|
const cfg = (needle: string | RegExp, text?: string): Hit[] => {
|
|
const f = inp.configFile;
|
|
if (!f) return [];
|
|
const i = typeof needle === 'string' ? f.text.indexOf(needle) : f.text.search(needle);
|
|
if (i < 0) return [];
|
|
const line = lineOf(f.text, needle);
|
|
return [{ file: f.path, line, text: text ?? f.text.split('\n')[line - 1]!.trim().slice(0, 160) }];
|
|
};
|
|
const dep = (id: string): Hit[] => p.sdks.filter(s => s.sdk.id === id).flatMap(s => s.deps.slice(0, 2).map(d => ({ file: d.file, line: d.line, text: `${d.name}${d.version ? ` ${d.version}` : ''}` })));
|
|
const sdkHits = (pred: (s: ProjectFacts['sdks'][number]) => boolean): Hit[] => p.sdks.filter(pred).flatMap(s => s.deps.slice(0, 1).map(d => ({ file: d.file, line: d.line, text: `${s.sdk.name}: ${d.name}${d.version ? ` ${d.version}` : ''}` })));
|
|
// the own sources first; built output (bundles, dist, the Capacitor webDir, minified files) only as a fallback
|
|
const src = (re: RegExp, max = 3) => grepOwn(p, re, max).hits;
|
|
const native = (path: string) => /\.(swift|m|mm|h)$/.test(path);
|
|
const apple = c ? !!c.apple : p.ios;
|
|
const play = c ? !!c.play : p.android;
|
|
const iosProject = p.ios && p.plistSources.length > 0;
|
|
const plistVal = (k: string) => p.plist.get(k);
|
|
|
|
// ---- accounts, sign-in, demo account, deletion ----
|
|
const siwa = [...(p.entitlements.has('com.apple.developer.applesignin') ? [{ ...p.entitlements.get('com.apple.developer.applesignin')!.at, text: 'com.apple.developer.applesignin' }] : []),
|
|
...dep('apple-signin'), ...src(SIGNALS.appleSignIn, 2)];
|
|
const thirdParty = [...sdkHits(s => s.sdk.login === 'google' || s.sdk.login === 'facebook'), ...src(SIGNALS.thirdPartyLogin, 2)];
|
|
const authSdk = sdkHits(s => s.sdk.category === 'auth');
|
|
const signInCode = src(SIGNALS.signIn, 2);
|
|
const signUpCode = src(SIGNALS.signUp, 2);
|
|
const accountsCfg = c?.accounts?.enabled ? cfg(/accounts\s*:/, 'from config (accounts.enabled)') : [];
|
|
const signIn = [...accountsCfg, ...authSdk, ...signInCode];
|
|
const creates = [...accountsCfg, ...signUpCode, ...thirdParty, ...siwa, ...sdkHits(s => s.sdk.login === 'firebase')];
|
|
// in-app deletion needs code evidence (a delete-account call, function or UI string): accounts.inApp alone is a claim
|
|
const deletionCode = src(SIGNALS.deleteAccount, 2);
|
|
const deletionClaim = c?.accounts?.inApp ? cfg('inApp', `from config (accounts.inApp: ${c.accounts.inApp})`) : [];
|
|
|
|
if (signIn.length) {
|
|
const ra = c?.reviewAccess;
|
|
const ev = [...cfg(/signInRequired/), ...signIn].slice(0, 4);
|
|
if (apple) {
|
|
if (ra?.signInRequired && !inp.demoAccount)
|
|
add({ rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review', evidence: ev,
|
|
fix: 'Create a demo account that sees every feature (not a sign-up the reviewer must do), put it in .storebound/credentials.json as reviewDemoAccount (never in the repo), then `storebound apple review-details`.', skill: 'review-access' });
|
|
else if (!ra)
|
|
add({ rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'high', title: 'The app has a sign-in, but the config does not say whether App Review needs a demo account', evidence: ev,
|
|
fix: 'Set reviewAccess { signInRequired, instructions } in storebound.config.ts. With signInRequired: true add reviewDemoAccount to .storebound/credentials.json; reviewers reject apps they cannot get into (or offer a fully featured demo mode).', skill: 'review-access' });
|
|
}
|
|
if (play && ra?.signInRequired && !inp.demoAccount)
|
|
add({ rule: 'play-app-access', guideline: 'Play: App access', store: 'play', severity: 'high', title: 'Sign-in is required but there are no reviewer credentials for Play App access', evidence: ev,
|
|
fix: 'Play Console > App content > App access: add the demo account and instructions (keep the credentials in .storebound/credentials.json only).', skill: 'review-access', ref: `${PLAY}9859455` });
|
|
}
|
|
if (creates.length) {
|
|
if (apple && !deletionCode.length && deletionClaim.length)
|
|
add({ rule: 'account-deletion', guideline: '5.1.1(v)', store: 'apple', severity: 'medium',
|
|
title: 'In-app account deletion is claimed by config (accounts.inApp), not found in code', evidence: [...deletionClaim, ...creates].slice(0, 4),
|
|
fix: 'Show the code path or the screen: a delete-account call (endpoint, deleteUser / user.delete()), a deletion function or a "Delete account" UI string in the app sources. If deletion lives behind a server-driven or web screen, check it on a device and accept this finding with scan.ignore (a reason). A config claim alone does not pass App Review.', skill: 'account-deletion' });
|
|
else if (apple && !deletionCode.length)
|
|
add({ rule: 'account-deletion', guideline: '5.1.1(v)', store: 'apple', severity: accountsCfg.length || signUpCode.length ? 'blocker' : 'high',
|
|
title: 'Accounts can be created but no in-app account deletion was found', evidence: creates.slice(0, 4),
|
|
fix: 'Add "Delete account" in the app (it deletes the account and its data, not only signs out; with Sign in with Apple also revoke the token), then set accounts.inApp to where it starts, e.g. "Settings > Account > Delete account".', skill: 'account-deletion' });
|
|
if (play && !c?.accounts?.deletionUrl)
|
|
add({ rule: 'play-deletion-url', guideline: 'Play: Account deletion', store: 'play', severity: c?.accounts?.enabled || signUpCode.length ? 'blocker' : 'high',
|
|
title: 'Accounts can be created but there is no web page to request account deletion (Data safety asks for it)', evidence: creates.slice(0, 4),
|
|
fix: 'Publish a page where people request account and data deletion without the app, set accounts.deletionUrl (and answer dataSafety.partialDeletion); Play also wants the in-app path.', skill: 'account-deletion', ref: `${PLAY}13327111` });
|
|
}
|
|
if (apple && thirdParty.length && !siwa.length)
|
|
add({ rule: 'login-services', guideline: '4.8', store: 'apple', severity: 'high', title: 'Third-party login (Google / Facebook / ...) without Sign in with Apple or another equivalent login option', evidence: thirdParty.slice(0, 4),
|
|
fix: 'Offer Sign in with Apple (Signing & Capabilities > Sign in with Apple, ASAuthorizationAppleIDProvider) or another login that limits data to name and email, lets people hide their email and does not track. Not needed when the app only uses your own account system, or for the exemptions in 4.8 (education / enterprise accounts, government ID, a client for that one service).', skill: 'review-rejections' });
|
|
|
|
// ---- purchases ----
|
|
const subs = c ? allSubscriptions(c.subscriptions) : [];
|
|
const restorable = [...(c?.products ?? []).filter(x => x.type !== 'consumable').map(x => ({ ...cfg(`'${x.id}'`)[0] ?? cfg(`"${x.id}"`)[0] ?? { file: inp.configFile?.path ?? 'storebound.config.ts', line: 1, text: '' }, text: `${x.id} (${x.type})` })),
|
|
...subs.slice(0, 2).map(x => ({ ...(cfg(`'${x.sub.id}'`)[0] ?? cfg(`"${x.sub.id}"`)[0] ?? { file: inp.configFile?.path ?? 'storebound.config.ts', line: 1, text: '' }), text: `${x.sub.id} (subscription)` }))];
|
|
const subCode = src(SIGNALS.subscriptionCode, 2);
|
|
const paySdk = sdkHits(s => s.sdk.category === 'payments');
|
|
if (!c && subCode.length && paySdk.length) restorable.push(...subCode);
|
|
if (apple && restorable.length && !src(SIGNALS.restore, 1).length)
|
|
add({ rule: 'restore-purchases', guideline: '3.1.1', store: 'apple', severity: 'high', title: 'Restorable purchases (non-consumables or subscriptions) but no Restore Purchases path was found', evidence: [...restorable.slice(0, 3), ...paySdk.slice(0, 1)],
|
|
fix: 'Add a visible "Restore Purchases" button (StoreKit 2: AppStore.sync(); StoreKit 1: restoreCompletedTransactions; RevenueCat: restorePurchases) on the store / paywall screen or in Settings.', skill: 'store-iap-setup' });
|
|
const hasSubs = subs.length > 0 || (!c && subCode.length > 0);
|
|
if (apple && hasSubs) {
|
|
const ev = subs.length ? cfg(/subscriptions\s*:/) : subCode;
|
|
if (c && !c.app.termsUrl)
|
|
add({ rule: 'subscription-info', guideline: '3.1.2(c)', store: 'apple', severity: 'blocker', title: 'Subscriptions without a Terms of Use (EULA) link', evidence: ev,
|
|
fix: 'Set app.termsUrl (Apple\'s standard EULA URL is fine) and link it in the app\'s purchase screen and in the App Store description (or a custom EULA in App Information).', skill: 'store-subscriptions' });
|
|
const desc = listingHits(inp.listings.apple, /./, { field: /^description$/ });
|
|
if (inp.listings.apple && desc.length) {
|
|
const all = desc.map(d => d.text).join('\n');
|
|
const termsUrl = c?.app.termsUrl;
|
|
if (!(termsUrl && all.includes(termsUrl)) && !/terms of use|terms of service|\beula\b|license agreement/i.test(all))
|
|
add({ rule: 'subscription-info', guideline: '3.1.2(c)', store: 'apple', severity: 'high', title: 'App Store description has no Terms of Use (EULA) link', evidence: desc.slice(0, 1),
|
|
fix: 'Add a line "Terms of Use: <app.termsUrl>" (and the privacy policy) to the description of every locale, or set a custom EULA in App Store Connect > App Information.', skill: 'store-subscriptions' });
|
|
if (!/renew|cancel/i.test(all))
|
|
add({ rule: 'subscription-info', guideline: '3.1.2(c)', store: 'apple', severity: 'medium', title: 'App Store description does not say the subscription renews automatically or how to cancel', evidence: desc.slice(0, 1),
|
|
fix: 'Describe what the subscription includes, the length and price per period, that it renews automatically unless cancelled at least 24 hours before the end of the period, and where to manage it.', skill: 'store-subscriptions' });
|
|
}
|
|
const legal = src(SIGNALS.inAppLegal, 1);
|
|
if (p.sources.length && !legal.length && !(c?.app.termsUrl && src(new RegExp(c.app.termsUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 1).length))
|
|
add({ rule: 'subscription-info', guideline: '3.1.2(c)', store: 'apple', severity: 'medium', title: 'No Terms of Use / privacy policy link found in the app\'s sources', evidence: ev.slice(0, 2),
|
|
fix: 'Show functional links to the Terms of Use and the privacy policy on the purchase screen, next to the price and period.', skill: 'store-subscriptions' });
|
|
}
|
|
|
|
// ---- App Tracking Transparency ----
|
|
if (apple && p.ios) {
|
|
const trackers = p.sdks.filter(s => s.sdk.tracking);
|
|
const trackEv = sdkHits(s => s.sdk.tracking);
|
|
const idfa = grep(p.sources, SIGNALS.idfa, 2, native);
|
|
const request = [...src(SIGNALS.attRequest, 2), ...p.deps.filter(d => ['expo-tracking-transparency', 'react-native-tracking-transparency', 'app_tracking_transparency', '@capacitor-community/app-tracking-transparency', 'com.unity.ads.ios-support'].includes(d.name)).map(d => ({ file: d.file, line: d.line, text: d.name }))];
|
|
const str = plistVal('NSUserTrackingUsageDescription');
|
|
const manifestTracking = p.privacyManifests.find(m => m.tracking);
|
|
const cfgTracking = c?.appPrivacy?.tracking === true;
|
|
const sdkPrompts = trackers.some(s => ATT_SDKS_SHOW_PROMPT.has(s.sdk.id));
|
|
if (request.length && !str && iosProject)
|
|
add({ rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'blocker', title: 'The app asks for tracking permission but Info.plist has no NSUserTrackingUsageDescription (crash on the prompt)', evidence: request.slice(0, 2),
|
|
fix: 'Add NSUserTrackingUsageDescription with a specific reason, e.g. "Your data is used to show ads that fit your interests."', skill: 'review-rejections' });
|
|
else if (str && !request.length && !sdkPrompts && p.sources.length)
|
|
add({ rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: 'high', title: 'NSUserTrackingUsageDescription is set but no App Tracking Transparency request was found (reviewers cannot find the prompt)', evidence: [{ ...str.at, text: 'NSUserTrackingUsageDescription' }],
|
|
fix: 'Call ATTrackingManager.requestTrackingAuthorization (after launch, when the app is active) before any SDK reads the IDFA, or remove the string and declare "no tracking" if no SDK tracks.', skill: 'review-rejections' });
|
|
if ((trackers.length || idfa.length || cfgTracking || manifestTracking) && !request.length && !str && !sdkPrompts)
|
|
add({ rule: 'att', guideline: '5.1.2(i)', store: 'apple', severity: idfa.length || cfgTracking || manifestTracking ? 'high' : 'medium',
|
|
title: `Tracking-capable code without App Tracking Transparency (${[...trackers.map(s => s.sdk.name), ...(idfa.length ? ['IDFA'] : [])].join(', ') || 'App Privacy says tracking'})`,
|
|
evidence: [...trackEv, ...idfa, ...(cfgTracking ? cfg(/tracking\s*:\s*true/) : []), ...(manifestTracking ? [{ file: manifestTracking.path, line: 1, text: 'NSPrivacyTracking true' }] : [])].slice(0, 4),
|
|
fix: 'If any SDK tracks (personalized ads, attribution, IDFA): add NSUserTrackingUsageDescription and request ATT before the SDKs start, and answer App Privacy "used for tracking". If nothing tracks: configure the ad SDKs for non-personalized ads and answer "no tracking".', skill: 'ads-mediation-checks' });
|
|
}
|
|
|
|
// ---- purpose strings ----
|
|
if (apple && iosProject) {
|
|
for (const perm of IOS_PERMISSIONS) {
|
|
if (perm.keys.includes('NSUserTrackingUsageDescription')) continue;
|
|
const used = [...grep(p.sources, perm.code, 2, x => native(x) || /\.(cs|gd)$/.test(x)),
|
|
...p.deps.filter(d => (d.eco === 'npm' || d.eco === 'pub' || d.eco === 'unity') && perm.packages.includes(d.name)).map(d => ({ file: d.file, line: d.line, text: d.name }))];
|
|
if (!used.length) continue;
|
|
const entry = perm.keys.map(k => plistVal(k)).find(Boolean);
|
|
if (!entry)
|
|
add({ rule: 'purpose-strings', guideline: '5.1.1(ii)', store: 'apple', severity: 'blocker', title: `${perm.what} is used but Info.plist has no ${perm.keys[0]} (ITMS-90683, crash on first use)`, evidence: used.slice(0, 2),
|
|
fix: `Add ${perm.keys[0]} to Info.plist (or INFOPLIST_KEY_${perm.keys[0]} in the target's build settings) with a sentence that says why the app needs the ${perm.what}, e.g. "Scan receipts to add them to your budget."`, skill: 'review-rejections' });
|
|
else {
|
|
const v = vaguePurpose(String(entry.value ?? ''));
|
|
if (v) add({ rule: 'purpose-strings', guideline: '5.1.1(ii)', store: 'apple', severity: v === 'empty' ? 'blocker' : 'medium',
|
|
title: `${entry.key} is ${v === 'empty' ? 'empty or a placeholder' : 'too vague'}: "${String(entry.value ?? '').slice(0, 60)}"`, evidence: [{ ...entry.at, text: `${entry.key}: ${String(entry.value ?? '').slice(0, 80)}` }],
|
|
fix: `Say what the ${perm.what} is used for and how it helps the user, in one specific sentence (and localize it in InfoPlist.strings).`, skill: 'review-rejections' });
|
|
}
|
|
}
|
|
// declared strings that are empty, even if the code use was not found
|
|
for (const [k, e] of p.plist) if (/^NS[A-Za-z]+UsageDescription$/.test(k) && vaguePurpose(String(e.value ?? '')) === 'empty' && !out.some(f => f.evidence.some(x => x.file === e.at.file && x.line === e.at.line)))
|
|
add({ rule: 'purpose-strings', guideline: '5.1.1(ii)', store: 'apple', severity: 'high', title: `${k} is empty or a placeholder`, evidence: [{ ...e.at, text: `${k}: ${String(e.value ?? '')}` }],
|
|
fix: 'Write a specific reason, or remove the key when the app does not use that permission.', skill: 'review-rejections' });
|
|
}
|
|
|
|
// ---- privacy manifest ----
|
|
if (apple && iosProject) {
|
|
const nativeSources = p.sources.filter(s => native(s.path));
|
|
const declared = new Map<string, string[]>();
|
|
for (const m of p.privacyManifests) for (const [k, v] of Object.entries(m.apis)) declared.set(k, v);
|
|
const listed = p.deps.filter(d => (d.eco === 'cocoapods' || d.eco === 'spm') && LISTED_SDKS.some(n => d.name === n || d.name.startsWith(`${n}/`) || d.name.toLowerCase().endsWith(`/${n.toLowerCase()}`) || d.name.toLowerCase().endsWith(`/${n.toLowerCase()}.git`)));
|
|
const apiHits: Hit[] = [];
|
|
for (const [cat, def] of Object.entries(REQUIRED_REASON_APIS)) {
|
|
const h = grep(nativeSources, def.symbols, 1);
|
|
if (!h.length) continue;
|
|
if (!declared.has(cat)) apiHits.push({ ...h[0]!, text: `${cat}: ${h[0]!.text}` });
|
|
}
|
|
if (!p.privacyManifests.length && (apiHits.length || listed.length || p.sdks.length))
|
|
add({ rule: 'privacy-manifest', guideline: 'Privacy manifest (ITMS-91053)', store: 'apple', severity: apiHits.length ? 'blocker' : 'high', title: 'No PrivacyInfo.xcprivacy in the app target', evidence: [...apiHits, ...listed.map(d => ({ file: d.file, line: d.line, text: d.name }))].slice(0, 4),
|
|
fix: 'Xcode > File > New > File > App Privacy, add it to the app target, declare each required-reason API category with its reason (`storebound privacy-manifest --ios <dir>` lists them) and the collected data types.', skill: 'privacy-manifest',
|
|
ref: 'https://developer.apple.com/documentation/bundleresources/privacy-manifest-files' });
|
|
else if (apiHits.length)
|
|
add({ rule: 'privacy-manifest', guideline: 'Privacy manifest (ITMS-91053)', store: 'apple', severity: 'blocker', title: `Required-reason APIs used but not declared in PrivacyInfo.xcprivacy: ${apiHits.map(h => h.text.split(':')[0]).join(', ')}`, evidence: apiHits.slice(0, 4),
|
|
fix: 'Add each category to NSPrivacyAccessedAPITypes with an approved reason code (for app data in UserDefaults: CA92.1).', skill: 'privacy-manifest', ref: 'https://developer.apple.com/documentation/bundleresources/privacy-manifest-files/describing-use-of-required-reason-api' });
|
|
// ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains must agree (true + empty list was refused at upload too)
|
|
for (const m of p.privacyManifests) {
|
|
const problem = trackingProblem(m);
|
|
if (!problem) continue;
|
|
const sdk = sdkOfProjectPath(m.path);
|
|
add({ rule: 'privacy-manifest', guideline: 'Privacy manifest (ITMS-91064)', store: 'apple', severity: 'blocker',
|
|
title: problem === 'true-without-domains' ? `${m.path}: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains (Apple refuses the build: ITMS-91064)` : `${m.path}: NSPrivacyTrackingDomains listed with NSPrivacyTracking ${m.tracking === false ? 'false' : 'missing'} (Apple refuses the build: ITMS-91064)`,
|
|
evidence: [{ file: m.path, line: 1, text: problem === 'true-without-domains' ? 'NSPrivacyTracking true, NSPrivacyTrackingDomains empty' : `NSPrivacyTrackingDomains: ${m.trackingDomains.join(', ')}` }],
|
|
fix: `${trackingIssue(m, sdk)!.split(' Fix: ')[1]} Apple: ${itms91064('PrivacyInfo.xcprivacy')}`, skill: 'privacy-manifest',
|
|
ref: 'https://developer.apple.com/documentation/bundleresources/privacy-manifest-files' });
|
|
}
|
|
for (const s of p.sdks) {
|
|
if (!s.sdk.manifestSince) continue;
|
|
for (const d of s.deps.filter(x => (x.eco === 'cocoapods' || x.eco === 'spm') && x.version)) {
|
|
if (versionAtLeast(d.version!, s.sdk.manifestSince) === false)
|
|
add({ rule: 'privacy-manifest', guideline: 'Privacy manifest (ITMS-91061)', store: 'apple', severity: 'high', title: `${s.sdk.name} ${d.version} is older than ${s.sdk.manifestSince}, the first version with Apple's privacy manifest`, evidence: [{ file: d.file, line: d.line, text: `${d.name} ${d.version}` }],
|
|
fix: `Update ${d.name} to ${s.sdk.manifestSince} or newer (the SDK is on Apple's list of commonly used SDKs that need a manifest and a signature).`, skill: 'privacy-manifest', ref: 'https://developer.apple.com/support/third-party-SDK-requirements/' });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- metadata (iOS listing + screenshot captions) ----
|
|
if (apple) {
|
|
const other = [...listingHits(inp.listings.apple, OTHER_PLATFORMS), ...listingHits(inp.listings.captions, OTHER_PLATFORMS, { fenced: false })];
|
|
if (other.length)
|
|
add({ rule: 'other-platforms', guideline: '2.3.10', store: 'apple', severity: 'high', title: 'Other mobile platforms or stores named in the App Store metadata or screenshot captions', evidence: other.slice(0, 4),
|
|
fix: 'Remove "Android", "Google Play" and other platforms or stores from the App Store text and screenshots (Play listing text is separate: store/listing.md).', skill: 'aso-basics' });
|
|
const words = competitorRe();
|
|
const kw = [...listingHits(inp.listings.apple, words, { field: /^(keywords|name|subtitle)$/ })];
|
|
if (kw.length)
|
|
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'high', title: 'Other apps\' names or trademarks in the name, subtitle or keywords', evidence: kw.slice(0, 4),
|
|
fix: 'Use only terms that describe your app; popular app names, trademarks and Apple product names in keywords get the metadata rejected.', skill: 'aso-basics' });
|
|
const price = listingHits(inp.listings.apple, PRICE_CLAIMS, { field: /^(name|subtitle|keywords)$/ });
|
|
if (price.length)
|
|
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Prices or claims ("free", "best", "#1") in the name, subtitle or keywords', evidence: price.slice(0, 3),
|
|
fix: 'Keep prices and superlatives out of the name, subtitle and keywords (keywords already match "free" searches only by category).', skill: 'aso-basics' });
|
|
const stuffed = listingHits(inp.listings.apple, /^([^,|]{1,24}[,|]){5,}/, { field: /^(name|subtitle)$/ });
|
|
if (stuffed.length)
|
|
add({ rule: 'metadata-keywords', guideline: '2.3.7', store: 'apple', severity: 'medium', title: 'Keyword list in the name or subtitle', evidence: stuffed.slice(0, 2), fix: 'Name and subtitle are a short phrase; keywords belong in the keywords field.', skill: 'aso-basics' });
|
|
// 2.2: demos, betas and trial versions belong on TestFlight, not on the App Store (the name and the listing say so)
|
|
const BETA = BETA_WORDS;
|
|
const betaName: Hit[] = [];
|
|
if (c?.app?.name && BETA.test(c.app.name)) betaName.push(...cfg(c.app.name, `app.name: ${c.app.name}`));
|
|
if (c?.apple?.name && c.apple.name !== c.app?.name && BETA.test(c.apple.name)) betaName.push(...cfg(c.apple.name, `apple.name: ${c.apple.name}`));
|
|
for (const k of ['CFBundleDisplayName', 'CFBundleName']) {
|
|
const v = plistVal(k);
|
|
if (typeof v?.value === 'string' && BETA.test(v.value) && !/\$\(/.test(v.value)) betaName.push({ ...v.at, text: `${k}: ${v.value}` });
|
|
}
|
|
const betaText = listingHits(inp.listings.apple, BETA, { field: /^(name|subtitle|promotionalText|keywords|description)$/ })
|
|
.filter(h => !/\bdemo account\b|\bdemo (video|mode)\b/i.test(h.text));
|
|
if (betaName.length || betaText.length)
|
|
add({ rule: 'beta-wording', guideline: '2.2', store: 'apple', severity: betaName.length ? 'high' : 'medium', title: '"Beta", "demo", "trial" or "test" wording in the app name or App Store text', evidence: [...betaName, ...betaText].slice(0, 4),
|
|
fix: 'Apple 2.2: demos, betas and trial versions do not belong on the App Store (use TestFlight). Remove the word from the name, subtitle, keywords and description; a free tier or a trial of a subscription is fine when it is described as such ("free trial of Pro").', skill: 'review-rejections' });
|
|
const kids = [...listingHits(inp.listings.apple, /\bfor (kids|children|toddlers)\b/i), ...listingHits(inp.listings.captions, /\bfor (kids|children|toddlers)\b/i, { fenced: false })];
|
|
const kidsBand = c?.apple?.info?.ageRating?.kidsAgeBand;
|
|
if (kids.length && !kidsBand)
|
|
add({ rule: 'kids-terms', guideline: '2.3.8', store: 'apple', severity: 'medium', title: '"For Kids" / "For Children" in the metadata, but the app is not in the Kids Category', evidence: kids.slice(0, 2),
|
|
fix: 'Those terms are reserved for the Kids Category: remove them, or set apple.info.ageRating.kidsAgeBand and follow 1.3.', skill: 'store-declarations' });
|
|
}
|
|
const placeholder = [...(apple ? listingHits(inp.listings.apple, PLACEHOLDER) : []), ...(play ? listingHits(inp.listings.play, PLACEHOLDER) : [])];
|
|
for (const store of ['apple', 'play'] as const) {
|
|
const ev = placeholder.filter(h => h.file === (store === 'apple' ? inp.listings.apple?.path : inp.listings.play?.path));
|
|
if (ev.length)
|
|
add({ rule: 'placeholder-text', guideline: store === 'apple' ? '2.1(a)' : 'Play: Metadata', store, severity: store === 'apple' ? 'high' : 'medium', title: 'Placeholder or template text in the store listing', evidence: ev.slice(0, 4),
|
|
fix: 'Replace the template lines and any lorem ipsum / TODO with the real copy before you push the listing.', skill: 'aso-basics', ref: store === 'apple' ? APPLE_GUIDELINES : `${PLAY}9898842` });
|
|
}
|
|
|
|
// ---- code and build settings ----
|
|
if (apple && p.remoteStart)
|
|
add({ rule: 'min-functionality', guideline: '4.2', store: 'apple', severity: 'high', title: `The app loads a remote website as its start page (${p.remoteStart.url})`, evidence: [{ ...p.remoteStart.at, text: p.remoteStart.url }],
|
|
fix: 'Ship the web app inside the binary (remove server.url / the remote <content src>) and add native value (offline use, push, native navigation, device features); a repackaged website is rejected under 4.2.', skill: 'review-rejections' });
|
|
else if (apple && iosProject) {
|
|
const swift = p.sources.filter(s => native(s.path));
|
|
const remote = grep(swift, SIGNALS.wkRemote, 1);
|
|
if (remote.length && swift.length <= 6)
|
|
add({ rule: 'min-functionality', guideline: '4.2', store: 'apple', severity: 'medium', title: 'A few native files that load a remote website in a WKWebView', evidence: remote,
|
|
fix: 'An app that only shows a website is rejected under 4.2: add native features or bundle the content.', skill: 'review-rejections' });
|
|
}
|
|
if (apple) {
|
|
const uiw = grep(p.sources, SIGNALS.uiWebView, 3, native);
|
|
if (uiw.length)
|
|
add({ rule: 'deprecated-api', guideline: '2.5.1 (ITMS-90809)', store: 'apple', severity: 'blocker', title: 'UIWebView is used (deprecated; App Store Connect rejects builds that reference it)', evidence: uiw,
|
|
fix: 'Replace UIWebView with WKWebView and update any SDK that still links it.', skill: 'review-rejections' });
|
|
}
|
|
if (apple && iosProject) {
|
|
const ats = plistVal('NSAppTransportSecurity');
|
|
const v = ats?.value as Record<string, unknown> | undefined;
|
|
if (v && v.NSAllowsArbitraryLoads === true)
|
|
add({ rule: 'ats', guideline: 'App Transport Security', store: 'apple', severity: 'medium', title: 'NSAllowsArbitraryLoads turns App Transport Security off for every connection', evidence: [{ ...ats!.at, text: 'NSAppTransportSecurity > NSAllowsArbitraryLoads = true' }],
|
|
fix: 'Use HTTPS, or limit the exception to the domains that need it (NSExceptionDomains); App Review asks for a justification of a global exception.', skill: 'review-rejections',
|
|
ref: 'https://developer.apple.com/documentation/security/preventing-insecure-network-connections' });
|
|
const ipv4 = src(SIGNALS.ipv4, 2);
|
|
if (ipv4.length)
|
|
add({ rule: 'ipv6', guideline: '2.5.5', store: 'apple', severity: 'medium', title: 'Hard-coded IPv4 address in a server URL (fails on IPv6-only networks, where App Review tests)', evidence: ipv4,
|
|
fix: 'Use a host name that resolves over IPv6 (DNS64/NAT64 then works) instead of an IPv4 literal.', skill: 'review-rejections' });
|
|
const enc = plistVal('ITSAppUsesNonExemptEncryption');
|
|
const cfgEnc = c?.apple?.usesNonExemptEncryption;
|
|
if (!enc && cfgEnc === undefined)
|
|
add({ rule: 'export-compliance', guideline: 'Export compliance', store: 'apple', severity: 'medium', title: 'Export compliance is not answered (no ITSAppUsesNonExemptEncryption, no apple.usesNonExemptEncryption)', evidence: p.plistSources.slice(0, 1).map(f => ({ file: f, line: 1, text: 'Info.plist' })),
|
|
fix: 'Ask the owner and decide it with the export-compliance skill (false only when the app uses no encryption beyond the OS); then set ITSAppUsesNonExemptEncryption in Info.plist and apple.usesNonExemptEncryption. Builds wait in "Missing Compliance" until it is answered.', skill: 'export-compliance',
|
|
ref: 'https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations' });
|
|
else if (enc && cfgEnc !== undefined && enc.value !== cfgEnc)
|
|
add({ rule: 'export-compliance', guideline: 'Export compliance', store: 'apple', severity: 'high', title: `Info.plist says ITSAppUsesNonExemptEncryption ${String(enc.value)} but the config says ${String(cfgEnc)}`, evidence: [{ ...enc.at, text: `ITSAppUsesNonExemptEncryption ${String(enc.value)}` }, ...cfg('usesNonExemptEncryption')],
|
|
fix: 'Make both the same answer (export-compliance skill).', skill: 'export-compliance' });
|
|
}
|
|
|
|
// ---- kids: ads / third-party analytics ----
|
|
const kidsBand = c?.apple?.info?.ageRating?.kidsAgeBand;
|
|
if (kidsBand) {
|
|
const third = sdkHits(s => s.sdk.category === 'ads' || s.sdk.category === 'attribution' || s.sdk.category === 'analytics');
|
|
if (third.length) {
|
|
if (apple) add({ rule: 'kids-ads', guideline: '1.3', store: 'apple', severity: 'high', title: `Kids Category app (${String(kidsBand)}) with third-party ads or analytics SDKs`, evidence: [...cfg('kidsAgeBand'), ...third].slice(0, 4),
|
|
fix: 'Kids Category apps should not include third-party advertising or analytics (narrow exceptions: contextual ads with human-reviewed creatives, analytics without IDFA or identifiable data). Remove them or configure child-directed mode and document it in the review notes.', skill: 'store-declarations' });
|
|
if (play && !c?.play?.targetAges) add({ rule: 'kids-ads', guideline: 'Play: Families policy', store: 'play', severity: 'high', title: 'App for children with ad / analytics SDKs: only Families self-certified ad SDKs are allowed', evidence: third.slice(0, 3),
|
|
fix: 'Use only ad SDKs from the Families Self-Certified Ads SDK list, turn on child-directed treatment, and answer Target audience and content accordingly.', skill: 'store-declarations', ref: `${PLAY}9893335` });
|
|
}
|
|
}
|
|
|
|
// ---- Google Play manifest and build ----
|
|
if (play && p.android) {
|
|
const a = p.androidInfo;
|
|
const need = PLAY_TARGET_SDK[a.formFactor];
|
|
if (a.targetSdk && a.targetSdk.value < need)
|
|
add({ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'blocker', title: `targetSdk ${a.targetSdk.value} is below ${need}, which new apps and updates need since ${PLAY_TARGET_SDK_SINCE}`, evidence: [{ ...a.targetSdk.at, text: `targetSdk ${a.targetSdk.value}` }],
|
|
fix: `Raise targetSdk (and compileSdk) to ${need}, test the behavior changes of each Android version in between, then upload a new AAB (an extension to 2026-11-01 can be requested in Play Console).`, skill: 'play-first-release',
|
|
ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
|
|
else if (!a.targetSdk && a.targetSdkFrom)
|
|
add({ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'low', title: `targetSdk comes from ${a.targetSdkFrom}: check that the built AAB targets ${need} or higher`, evidence: a.manifests.slice(0, 1).map(f => ({ file: f, line: 1, text: 'AndroidManifest.xml' })),
|
|
fix: `Check with \`aapt2 dump badging app.aab\` or Play Console > App bundle explorer; Play refuses uploads below ${need}.`, skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
|
|
const perms = new Map(a.permissions.map(x => [x.name.replace(/^android\.permission\./, ''), x] as const));
|
|
const fgs = perms.get('FOREGROUND_SERVICE');
|
|
if (fgs && (!a.targetSdk || a.targetSdk.value >= 34)) {
|
|
const typed = a.services.filter(s => s.fgsType);
|
|
const untyped = a.services.filter(s => !s.fgsType);
|
|
if (untyped.length && !typed.length)
|
|
add({ rule: 'play-fgs', guideline: 'Play: Foreground service types', store: 'play', severity: 'high', title: 'FOREGROUND_SERVICE is requested but no <service> declares android:foregroundServiceType (required when targeting Android 14+)', evidence: [{ ...fgs.at, text: 'FOREGROUND_SERVICE' }, ...untyped.slice(0, 2).map(s => ({ ...s.at, text: `service ${s.name}` }))],
|
|
fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill App content > Foreground service permissions (description, user impact, video).', skill: 'play-first-release', ref: `${PLAY}13392821` });
|
|
for (const s of typed) {
|
|
const types = s.fgsType!.split('|').map(x => x.trim()).filter(Boolean);
|
|
const missing = types.filter(t => FGS_PERMISSION[t] && !perms.has(`FOREGROUND_SERVICE_${FGS_PERMISSION[t]}`));
|
|
if (missing.length)
|
|
add({ rule: 'play-fgs', guideline: 'Play: Foreground service types', store: 'play', severity: 'high', title: `Service ${s.name} has type ${missing.join('|')} without FOREGROUND_SERVICE_${missing.map(t => FGS_PERMISSION[t]).join(' / ')} (SecurityException on Android 14+)`, evidence: [{ ...s.at, text: `foregroundServiceType="${s.fgsType}"` }],
|
|
fix: `Add <uses-permission android:name="android.permission.FOREGROUND_SERVICE_${FGS_PERMISSION[missing[0]!]}" />.`, skill: 'play-first-release', ref: `${PLAY}13392821` });
|
|
}
|
|
if (typed.length)
|
|
add({ rule: 'play-fgs', guideline: 'Play: Foreground service types', store: 'play', severity: 'medium', title: `Foreground service types need a Play Console declaration: ${[...new Set(typed.flatMap(s => s.fgsType!.split('|')))].join(', ')}`, evidence: typed.slice(0, 3).map(s => ({ ...s.at, text: `${s.name}: ${s.fgsType}` })),
|
|
fix: 'App content > Foreground service permissions: describe each use, the user impact if it is deferred, and add a short video; shortService needs none.', skill: 'play-first-release', ref: `${PLAY}13392821` });
|
|
}
|
|
const exact = perms.get('USE_EXACT_ALARM');
|
|
if (exact)
|
|
add({ rule: 'play-exact-alarm', guideline: 'Play: Exact alarm permission', store: 'play', severity: 'high', title: 'USE_EXACT_ALARM is only allowed for alarm clock, timer and calendar apps', evidence: [{ ...exact.at, text: 'USE_EXACT_ALARM' }],
|
|
fix: 'Remove it (an SDK may add it: check the merged manifest and use tools:node="remove"), or use SCHEDULE_EXACT_ALARM (the user grants it) or inexact alarms / WorkManager; alarm and calendar apps fill the Play Console declaration.', skill: 'play-first-release', ref: `${PLAY}9888170` });
|
|
const sched = perms.get('SCHEDULE_EXACT_ALARM');
|
|
if (sched && !exact)
|
|
add({ rule: 'play-exact-alarm', guideline: 'Play: Exact alarm permission', store: 'play', severity: 'low', title: 'SCHEDULE_EXACT_ALARM: denied by default from Android 14; the app must check canScheduleExactAlarms()', evidence: [{ ...sched.at, text: 'SCHEDULE_EXACT_ALARM' }],
|
|
fix: 'Keep it only for user-facing, time-critical features and handle the denied case; otherwise use inexact alarms.', skill: 'play-first-release', ref: `${PLAY}9888170` });
|
|
const qap = perms.get('QUERY_ALL_PACKAGES');
|
|
if (qap)
|
|
add({ rule: 'play-query-all-packages', guideline: 'Play: Package visibility', store: 'play', severity: 'high', title: 'QUERY_ALL_PACKAGES needs a core purpose that requires seeing every installed app, and a declaration', evidence: [{ ...qap.at, text: 'QUERY_ALL_PACKAGES' }],
|
|
fix: 'Replace it with <queries> entries for the packages or intents you need; keep it only for launchers, device search, antivirus, file managers, browsers and similar, and fill the Permissions Declaration Form.', skill: 'play-first-release', ref: `${PLAY}10158779` });
|
|
const sms = SMS_CALL_LOG.map(x => perms.get(x)).filter((x): x is NonNullable<typeof x> => !!x);
|
|
if (sms.length)
|
|
add({ rule: 'play-sms-call-log', guideline: 'Play: SMS and Call Log permissions', store: 'play', severity: 'high', title: `Restricted SMS / Call Log permissions: ${sms.map(x => x.name.replace(/^android\.permission\./, '')).join(', ')}`, evidence: sms.slice(0, 3).map(x => ({ ...x.at, text: x.name })),
|
|
fix: 'Only the default SMS / Phone / Assistant handler may use them (or a listed exception with a declaration). Remove them; for one-time codes use the SMS Retriever or SMS User Consent API.', skill: 'play-first-release', ref: `${PLAY}10208820` });
|
|
}
|
|
|
|
// ---- privacy policy in the app, privacy labels vs SDKs, unused permissions ----
|
|
const collects = p.sdks.length > 0 || creates.length > 0;
|
|
if (apple && collects && p.sources.length && !src(SIGNALS.privacyLink, 1).length && !(c?.app.privacyPolicyUrl && src(new RegExp(c.app.privacyPolicyUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), 1).length))
|
|
add({ rule: 'privacy-policy-link', guideline: '5.1.1(i)', store: 'apple', severity: 'medium', title: 'The app collects data (SDKs or accounts) but no privacy policy link was found in its sources', evidence: [...sdkHits(() => true), ...creates].slice(0, 3),
|
|
fix: 'Link the privacy policy (app.privacyPolicyUrl) somewhere easy to find in the app, e.g. Settings > Privacy policy, and on sign-up / purchase screens.', skill: 'privacy-policy' });
|
|
for (const g of inp.labelGaps ?? []) {
|
|
if (apple) add({ rule: 'privacy-labels', guideline: '5.1.2', store: 'apple', severity: 'medium', title: `App Privacy likely misses "${g.apple}": an SDK collects it, the config does not declare it`, evidence: g.evidence.slice(0, 2),
|
|
fix: 'Run `storebound scan` and read the declarations proposal; confirm with the SDK\'s disclosure, then add the type to dataSafety (`storebound scan --write`) and to App Privacy in App Store Connect.', skill: 'store-declarations' });
|
|
if (play) add({ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: `Data safety likely misses ${g.type}: an SDK collects it, dataSafety does not declare it`, evidence: g.evidence.slice(0, 2),
|
|
fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: `${PLAY}10787469` });
|
|
}
|
|
if (apple && iosProject && p.stacks.length === 1 && p.stacks[0] === 'ios-native') {
|
|
for (const perm of IOS_PERMISSIONS) {
|
|
const entry = perm.keys.map(k => plistVal(k)).find(Boolean);
|
|
if (!entry || perm.keys.includes('NSUserTrackingUsageDescription') || perm.keys.includes('NSPhotoLibraryUsageDescription')) continue;
|
|
if (grep(p.sources, perm.code, 1, native).length || p.sdks.length) continue;
|
|
add({ rule: 'unused-permission', guideline: '5.1.1(iv)', store: 'apple', severity: 'low', title: `${entry.key} is declared but no ${perm.what} use was found in the app's sources`, evidence: [{ ...entry.at, text: entry.key }],
|
|
fix: 'Remove purpose strings the app does not need; ask only for the access a feature needs, when it needs it.', skill: 'review-rejections' });
|
|
}
|
|
}
|
|
|
|
// ---- payments outside in-app purchase, subscription management ----
|
|
const extPay = sdkHits(s => s.sdk.category === 'external-payments');
|
|
if (apple && extPay.length && (restorable.length || paySdk.length || (c?.products?.length ?? 0) > 0))
|
|
add({ rule: 'external-purchase', guideline: '3.1.1', store: 'apple', severity: 'medium', title: 'A card / PayPal payment SDK next to in-app purchases: digital goods and features must be sold with in-app purchase', evidence: [...extPay, ...restorable].slice(0, 3),
|
|
fix: 'Use the external payment SDK only for physical goods or services used outside the app (3.1.3(e)); sell unlocks, coins and subscriptions through StoreKit.', skill: 'review-rejections' });
|
|
const buyOutside = apple ? [...listingHits(inp.listings.apple, SIGNALS.externalBuy), ...grep(p.sources, SIGNALS.externalBuy, 2, x => native(x) || /\.(dart|tsx?|jsx?|cs|vue|html)$/.test(x))] : [];
|
|
if (buyOutside.length)
|
|
add({ rule: 'external-purchase', guideline: '3.1.1', store: 'apple', severity: 'medium', title: 'A call to action to buy outside the app (website) in the iOS app or its metadata', evidence: buyOutside.slice(0, 3),
|
|
fix: 'Outside the storefronts and entitlements that allow external purchase links, remove buttons, links and text that send people to buy digital content elsewhere.', skill: 'review-rejections' });
|
|
if (apple && hasSubs && p.sources.length && !src(SIGNALS.manageSubs, 1).length)
|
|
add({ rule: 'subscription-info', guideline: '3.1.2', store: 'apple', severity: 'low', title: 'No "Manage subscription" entry found in the app', evidence: (subs.length ? cfg(/subscriptions\s*:/) : subCode).slice(0, 1),
|
|
fix: 'Offer a way to manage or cancel in the app (StoreKit showManageSubscriptions / SwiftUI manageSubscriptionsSheet, or a link to the App Store subscriptions page).', skill: 'store-subscriptions' });
|
|
|
|
// ---- private URL schemes, review prompts ----
|
|
if (apple) {
|
|
const priv = grep(p.sources, SIGNALS.privateScheme, 2);
|
|
if (priv.length)
|
|
add({ rule: 'private-api', guideline: '2.5.1', store: 'apple', severity: 'high', title: 'Private settings URL scheme (App-Prefs: / prefs:root)', evidence: priv,
|
|
fix: 'Open your own settings page with UIApplication.openSettingsURLString instead.', skill: 'review-rejections' });
|
|
const incent = [...grep(p.sources, SIGNALS.incentiveRating, 2), ...rewardNearReviewApi(p.sources, 2)].slice(0, 2);
|
|
if (incent.length)
|
|
add({ rule: 'review-prompt', guideline: '3.2.2(x) / 5.6.3', store: 'apple', severity: 'high', title: 'A reward for rating or reviewing the app', evidence: incent,
|
|
fix: 'Never reward or gate ratings; ask with the system review prompt only.', skill: 'review-replies' });
|
|
const custom = grep(p.sources, SIGNALS.ratePrompt, 2);
|
|
if (custom.length && !src(SIGNALS.reviewApi, 1).length && !p.deps.some(d => /review|rate/i.test(d.name)))
|
|
add({ rule: 'review-prompt', guideline: '5.6.1', store: 'apple', severity: 'medium', title: 'A custom "rate us" prompt without the system review API', evidence: custom,
|
|
fix: 'Ask for ratings with SKStoreReviewController / requestReview (StoreKit); custom review prompts are not allowed.', skill: 'review-replies' });
|
|
}
|
|
if (play && c?.play && /^com\.example(\.|$)/.test(c.play.packageName))
|
|
add({ rule: 'play-package-name', guideline: 'Play: Package name', store: 'play', severity: 'blocker', title: `Package name ${c.play.packageName} uses the reserved com.example prefix`, evidence: cfg('packageName'),
|
|
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' });
|
|
|
|
// ---- the other Google Play policies (play-rules.ts) ----
|
|
if (play)
|
|
playRules(inp, { add: f => add({ ...f, store: 'play' }), cfg, sdkHits, listingHits, creates, restorable, paySdk });
|
|
|
|
// ---- owner-accepted findings ----
|
|
applyIgnore(out, c?.scan?.ignore ?? []);
|
|
return sortScan(out);
|
|
}
|
|
|
|
export type ScanIgnore = NonNullable<NonNullable<ShipConfig['scan']>['ignore']>[number];
|
|
|
|
/** Whether a `scan.ignore` entry accepts a finding: the same rule (and store, when given), `match` in the title and
|
|
* `evidence` in one evidence line ("file:line text"), both case-insensitive. */
|
|
export function ignoreMatches(i: ScanIgnore, f: ScanFinding): boolean {
|
|
if (i.rule !== f.rule || (i.store && i.store !== f.store)) return false;
|
|
if (i.match && !f.title.toLowerCase().includes(i.match.toLowerCase())) return false;
|
|
const ev = i.evidence?.toLowerCase();
|
|
return !ev || f.evidence.some(e => `${e.file}:${e.line} ${e.text}`.toLowerCase().includes(ev));
|
|
}
|
|
|
|
/** Marks the findings a `scan.ignore` entry accepts (the first matching entry's reason). */
|
|
export function applyIgnore(findings: ScanFinding[], ignore: ScanIgnore[]): void {
|
|
for (const f of findings) if (!f.accepted) {
|
|
const hit = ignore.find(i => ignoreMatches(i, f));
|
|
if (hit) f.accepted = hit.reason;
|
|
}
|
|
}
|
|
|
|
/** `scan.ignore` entries that accept no finding (a typo in the rule id, a title or evidence that changed, a fixed
|
|
* issue): each as a warning line. `skipRules`: rules this run could not produce (originality without --originality). */
|
|
export function unusedIgnores(findings: ScanFinding[], ignore: ScanIgnore[], skipRules: string[] = ['originality']): string[] {
|
|
return ignore.flatMap((i, n) => skipRules.includes(i.rule) || findings.some(f => ignoreMatches(i, f)) ? []
|
|
: [`scan.ignore[${n}] (rule ${i.rule}${i.store ? `, store ${i.store}` : ''}${i.match ? `, match "${i.match}"` : ''}${i.evidence ? `, evidence "${i.evidence}"` : ''}) matches no finding: check the rule id, the title text (match) and the evidence text (evidence), or remove it if the issue is fixed`]);
|
|
}
|
|
|
|
const RANK: Record<Severity, number> = { blocker: 0, high: 1, medium: 2, low: 3 };
|
|
/** Open findings first (blocker, high, medium, low), owner-accepted ones last; stable inside a level. */
|
|
export function sortScan(f: ScanFinding[]): ScanFinding[] {
|
|
return f.map((x, i) => [x, i] as const).sort(([a, i], [b, j]) => Number(!!a.accepted) - Number(!!b.accepted) || RANK[a.severity] - RANK[b.severity] || i - j).map(([x]) => x);
|
|
}
|