scan, privacy answers and credentials from a second game's first release (lessons 35-37): review-prompt needs rating context, not a bare "rate"; account signals skip import lines and module paths, config-only accounts say "from config"; accounts.inApp without deletion code stays a medium 5.1.1(v) finding; privacy-manifest --propose builds NSPrivacyCollectedDataTypes from appPrivacy.types and check warns on an empty list; appPrivacy.types is authoritative in the App Privacy sheet (unlisted = not collected), ad / support / AR types default to not collected without tracking or ad SDKs; Play advertising ID from SDKs, the AD_ID permission, app-ads.txt or the flag, never from a Device ID alone; scan.ignore[].evidence and warnings for entries that match nothing; empty credentials template, placeholder values count as missing (check, doctor)

This commit is contained in:
CRTkafa 2026-10-13 05:39:09 +08:00
parent 86cd79dd45
commit 1366e7e231
34 changed files with 881 additions and 71 deletions

View file

@ -72,13 +72,49 @@ export function profileStatus(profile: string): { profile: string; source: 'file
}
}
function value(root: string, name: string): string | undefined {
/**
* A value that only looks like a credential: a template placeholder ("your-…", "YOUR_KEY_ID", "<KEY_ID>", "xxx",
* "/absolute/path/to/…", the all-zero Apple Ads client id, "example" as a word unless it is a file that exists). Treated as
* missing everywhere (check, apps check, doctor and every command), never sent to a store. `example: false` for the
* review contact and demo account (an app's own backend can have a real demo@example.com account).
*/
export function isPlaceholder(v: string, o: { example?: boolean } = {}): boolean {
const s = v.trim();
if (!s || /BEGIN PRIVATE KEY/.test(s) || /^\s*\{/.test(s)) return false;
if (/(^|[_\-\s/])your([_\-\s]|$)/i.test(s) || /<[^<>\n]+>/.test(s) || /(^|[^a-z0-9])x{3,}([^a-z0-9]|$)/i.test(s) || /(^|\/)path\/to\//.test(s)) return true;
if (/^SEARCHADS\.0{8}-0{4}-0{4}-0{4}-0{12}$/i.test(s)) return true;
if (o.example !== false && /(^|[^a-z0-9])example([^a-z0-9]|$)/i.test(s)) {
try { return !existsSync(s.replace(/^~(?=\/)/, homedir())); } catch { return true; }
}
return false;
}
/** Credential names storebound reads (env, .storebound/credentials.json, a profile), for status reports. */
export const CREDENTIAL_KEYS = ['PLAY_SERVICE_ACCOUNT', 'ASC_KEY_ID', 'ASC_ISSUER_ID', 'ASC_P8', 'ASC_VENDOR_NUMBER', 'APPLE_ADS_CLIENT_ID', 'APPLE_ADS_TEAM_ID',
'APPLE_ADS_KEY_ID', 'APPLE_ADS_PRIVATE_KEY', 'APPLE_ADS_ACCOUNT_ID', 'REVENUECAT_SECRET_KEY', 'ANDROID_KEYSTORE', 'ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD'] as const;
/** Where each known credential stands: set, missing, or placeholder (a template value, treated as missing). Never
* the values. */
export function credentialStatus(root: string): { key: string; status: 'set' | 'missing' | 'placeholder' }[] {
let file: Store = {};
try { file = fileStore(root); } catch { /* unreadable: reported by the commands that read it */ }
const profile = process.env.STOREBOUND_PROFILE;
if (profile) { const v = profileEnv(profile)[name]; if (v) return v; }
let prof: Record<string, string> = {};
try { if (profile) prof = profileEnv(profile); } catch { /* invalid profile */ }
return CREDENTIAL_KEYS.map(key => {
const raw = [prof[key], process.env[key], typeof file[key] === 'string' ? file[key] as string : undefined].filter((x): x is string => !!x && !!x.trim());
return { key, status: raw.some(x => !isPlaceholder(x)) ? 'set' as const : raw.length ? 'placeholder' as const : 'missing' as const };
});
}
function value(root: string, name: string): string | undefined {
const real = (v: unknown): v is string => typeof v === 'string' && !!v && !isPlaceholder(v);
const profile = process.env.STOREBOUND_PROFILE;
if (profile) { const v = profileEnv(profile)[name]; if (real(v)) return v; }
const env = process.env[name];
if (env) return env;
if (real(env)) return env;
const v = fileStore(root)[name];
return typeof v === 'string' && v ? v : undefined;
return real(v) ? v : undefined;
}
/** A path or the inline content itself. */
@ -109,7 +145,7 @@ export function ascKey(root: string): AscKey {
/** App Review contact from .storebound/credentials.json (personal data, so never in the config). */
export function reviewContact(root: string): ReviewContact | null {
const c = fileStore(root).reviewContact as Partial<ReviewContact> | undefined;
if (!c?.email) return null;
if (!c?.email || isPlaceholder(c.email, { example: false })) return null;
return { firstName: c.firstName ?? '', lastName: c.lastName ?? '', phone: c.phone ?? '', email: c.email };
}
@ -121,7 +157,7 @@ export function reviewDemoAccount(root: string): DemoAccount | null {
const eu = process.env.REVIEW_DEMO_USER, ep = process.env.REVIEW_DEMO_PASSWORD;
if (eu && ep) return { username: eu, password: ep };
const d = fileStore(root).reviewDemoAccount as Partial<DemoAccount> | undefined;
if (typeof d?.username === 'string' && d.username && typeof d.password === 'string' && d.password) return { username: d.username, password: d.password };
if (typeof d?.username === 'string' && d.username && !isPlaceholder(d.username, { example: false }) && typeof d.password === 'string' && d.password) return { username: d.username, password: d.password };
return null;
}