appstore-precheck 1.1.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -0
- package/README.md +238 -55
- package/package.json +2 -2
- package/skills/appstore-precheck/SKILL.md +201 -59
- package/skills/appstore-precheck/evals/README.md +3 -2
- package/skills/appstore-precheck/evals/evals.json +6 -3
- package/skills/appstore-precheck/guidelines-baseline.json +19 -7
- package/skills/appstore-precheck/references/methodology.md +85 -7
- package/skills/appstore-precheck/references/pierre-deep-review.md +272 -0
- package/skills/appstore-precheck/scripts/scan.sh +335 -13
- package/skills/appstore-precheck/scripts/verdict.sh +3 -3
|
@@ -134,7 +134,9 @@ fi
|
|
|
134
134
|
|
|
135
135
|
# Locales: explicit config array, else the directory names under metadata/.
|
|
136
136
|
declare -a LOCALES=()
|
|
137
|
+
LOCALES_FROM_CONFIG=""
|
|
137
138
|
if [[ -f "$CONFIG" ]] && have_jq && [[ "$(jq -r '.locales | type' "$CONFIG" 2>/dev/null)" == "array" ]]; then
|
|
139
|
+
LOCALES_FROM_CONFIG=1
|
|
138
140
|
while IFS= read -r l; do LOCALES+=("$l"); done < <(jq -r '.locales[]' "$CONFIG" 2>/dev/null)
|
|
139
141
|
elif [[ -d "$META_DIR" ]]; then
|
|
140
142
|
while IFS= read -r d; do
|
|
@@ -151,24 +153,28 @@ CHECK_FAMILY="$(cfg_bool '.optionalChecks.familyControls')"
|
|
|
151
153
|
echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='${XCSTRINGS:-?}' locales=${#LOCALES[@]}"
|
|
152
154
|
|
|
153
155
|
# ===================================================================
|
|
154
|
-
# §1 — 5.1.1
|
|
156
|
+
# §1 — 5.1.1 Privacy Manifest / Required Reason API parity
|
|
157
|
+
# Apple documents the Required Reason API rules under 5.1.1 (Data Collection and
|
|
158
|
+
# Storage) + the privacy-manifest developer docs; it is NOT roman sub-item (v).
|
|
159
|
+
# (v) "Account Sign-In" is a different rule — its account-deletion requirement is
|
|
160
|
+
# checked separately in §38.
|
|
155
161
|
# ===================================================================
|
|
156
162
|
check_required_reason_api() {
|
|
157
163
|
local cat="$1" pattern="$2" hits declared
|
|
158
164
|
hits=$(grep -rEl "$pattern" "$IOS_DIR" --include="*.swift" 2>/dev/null | head -3)
|
|
159
165
|
declared=$(grep -c "NSPrivacyAccessedAPICategory${cat}" "$PRIVACY_FILE" 2>/dev/null)
|
|
160
166
|
if [[ -n "$hits" && "${declared:-0}" -eq 0 ]]; then
|
|
161
|
-
fail "5.1.1
|
|
167
|
+
fail "5.1.1 Required Reason API — '$cat' used in code (e.g. $(echo "$hits" | head -1)) but not declared in PrivacyInfo.xcprivacy"
|
|
162
168
|
elif [[ -z "$hits" && "${declared:-0}" -gt 0 ]]; then
|
|
163
|
-
warn "5.1.1
|
|
169
|
+
warn "5.1.1 PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)"
|
|
164
170
|
elif [[ -n "$hits" && "${declared:-0}" -gt 0 ]]; then
|
|
165
|
-
pass "5.1.1
|
|
171
|
+
pass "5.1.1 Required Reason API — '$cat' parity OK"
|
|
166
172
|
fi
|
|
167
173
|
}
|
|
168
174
|
if [[ -z "$IOS_DIR" ]]; then
|
|
169
175
|
warn "layout — could not auto-detect iOS source dir; set .iosSourceDir in $CONFIG"
|
|
170
176
|
elif [[ -z "$PRIVACY_FILE" ]]; then
|
|
171
|
-
fail "5.1.1
|
|
177
|
+
fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)"
|
|
172
178
|
else
|
|
173
179
|
check_required_reason_api "UserDefaults" 'UserDefaults|@AppStorage'
|
|
174
180
|
check_required_reason_api "FileTimestamp" 'attributesOfItem|creationDate|modificationDate|\.fileCreationDate|\.fileModificationDate'
|
|
@@ -266,7 +272,18 @@ if (( ${#LOCALES[@]} > 0 )); then
|
|
|
266
272
|
expected_files=(name.txt subtitle.txt description.txt keywords.txt)
|
|
267
273
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
268
274
|
d="$META_DIR/$loc"
|
|
269
|
-
if [[ ! -d "$d" ]]; then
|
|
275
|
+
if [[ ! -d "$d" ]]; then
|
|
276
|
+
# A locale listed in .appstore-precheck.json but absent on disk is a
|
|
277
|
+
# config/reality mismatch, not a build fault: the locale was simply never
|
|
278
|
+
# submitted. Warn (don't block) so an approved set isn't falsely RED.
|
|
279
|
+
# Auto-detected locales always exist, so this only fires in config mode.
|
|
280
|
+
if [[ -n "$LOCALES_FROM_CONFIG" ]]; then
|
|
281
|
+
warn "2.3.7 Locale '$loc' is in .appstore-precheck.json but has no metadata folder ($d) — add it or remove '$loc' from the config 'locales' list"
|
|
282
|
+
else
|
|
283
|
+
fail "2.3.7 Locale missing — $d does not exist"
|
|
284
|
+
fi
|
|
285
|
+
continue
|
|
286
|
+
fi
|
|
270
287
|
for f in "${expected_files[@]}"; do
|
|
271
288
|
[[ -s "$d/$f" ]] || fail "2.3.7 Metadata missing — $d/$f is empty or absent"
|
|
272
289
|
done
|
|
@@ -370,13 +387,13 @@ else
|
|
|
370
387
|
fi
|
|
371
388
|
|
|
372
389
|
# ===================================================================
|
|
373
|
-
# §12 — 4.
|
|
390
|
+
# §12 — 4.2 Minimum functionality — navigation hubs
|
|
374
391
|
# ===================================================================
|
|
375
392
|
tab_count=$(grep -rcE 'TabView|NavigationStack|NavigationSplitView' "$IOS_DIR" --include="*.swift" 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
|
|
376
393
|
if (( tab_count < 1 )); then
|
|
377
|
-
warn "4.
|
|
394
|
+
warn "4.2 Minimum functionality — no TabView/NavigationStack found (heuristic, may be a false positive)"
|
|
378
395
|
else
|
|
379
|
-
pass "4.
|
|
396
|
+
pass "4.2 Minimum functionality — $tab_count navigation hub(s) found"
|
|
380
397
|
fi
|
|
381
398
|
|
|
382
399
|
# ===================================================================
|
|
@@ -460,10 +477,10 @@ if [[ -d "$META_DIR" ]]; then
|
|
|
460
477
|
[[ -s "$META_DIR/$loc/privacy_url.txt" ]] && privacy_found=1
|
|
461
478
|
done
|
|
462
479
|
if (( ${#LOCALES[@]} > 0 )); then
|
|
463
|
-
[[ -z "$support_found" ]] && warn "2.3 Support URL — no non-empty support_url.txt in any locale under fastlane metadata; Apple requires a working support URL"
|
|
464
|
-
[[ -z "$privacy_found" ]] && warn "2.3 Privacy URL — no non-empty privacy_url.txt in any locale under fastlane metadata; required for apps with accounts or in-app purchases"
|
|
480
|
+
[[ -z "$support_found" ]] && warn "2.3 Support URL — no non-empty support_url.txt in any locale under fastlane metadata; Apple requires a working support URL with developer contact info (1.5 / 2.3)"
|
|
481
|
+
[[ -z "$privacy_found" ]] && warn "2.3 Privacy URL — no non-empty privacy_url.txt in any locale under fastlane metadata; a privacy policy link is required for every app, and especially apps with accounts or in-app purchases (5.1.1(i))"
|
|
465
482
|
fi
|
|
466
|
-
url_ph=$(grep -rEnI 'example\.com|localhost|\bTODO\b
|
|
483
|
+
url_ph=$(grep -rEnI 'example\.com|localhost|\bTODO\b|\bchangeme\b' "$META_DIR" --include='*_url.txt' 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
467
484
|
if [[ -n "$url_ph" ]]; then
|
|
468
485
|
warn "2.3 Metadata URL — placeholder URL in fastlane metadata (replace before submitting):"
|
|
469
486
|
echo "$url_ph" | sed 's/^/ /'
|
|
@@ -502,7 +519,7 @@ fi
|
|
|
502
519
|
# avoid flagging legitimate words. Overlaps the Phase 2 precheck "No placeholder
|
|
503
520
|
# text" rule, but this one is local and runs before any network call.
|
|
504
521
|
if [[ -d "$META_DIR" ]]; then
|
|
505
|
-
ph_re='lorem ipsum|Lorem ipsum|\bTODO\b|\bFIXME\b|example\.com|placeholder|insert .* here
|
|
522
|
+
ph_re='lorem ipsum|Lorem ipsum|\bTODO\b|\bFIXME\b|example\.com|placeholder|insert .* here|\bchangeme\b'
|
|
506
523
|
ph_hits=$(grep -rEnI "$ph_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -20)
|
|
507
524
|
if [[ -n "$ph_hits" ]]; then
|
|
508
525
|
warn "2.1 Metadata content — placeholder/dummy text in store metadata (looks unfinished; rejected under 2.1):"
|
|
@@ -510,4 +527,309 @@ if [[ -d "$META_DIR" ]]; then
|
|
|
510
527
|
fi
|
|
511
528
|
fi
|
|
512
529
|
|
|
530
|
+
# ===================================================================
|
|
531
|
+
# §21 — 3.1.1 Third-party payment SDK for digital goods
|
|
532
|
+
# ===================================================================
|
|
533
|
+
# Apple requires in-app purchase to unlock digital content/functionality. A
|
|
534
|
+
# third-party payment SDK (Stripe, Braintree, PayPal, …) is legitimate for
|
|
535
|
+
# PHYSICAL goods/services but a frequent rejection when used to sell digital
|
|
536
|
+
# content. We can't tell digital from physical statically, so this is advisory.
|
|
537
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
538
|
+
payment_sdk=$(grep -rlE 'import Stripe|StripePaymentSheet|StripeApplePay|import Braintree|BTPaymentFlow|import PayPal|PayPalCheckout|PayPalNativeCheckout|import Square|SquareInAppPayments|import Adyen|AdyenComponents|RazorpaySDK|import Paddle' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
539
|
+
if [[ -n "$payment_sdk" ]]; then
|
|
540
|
+
warn "3.1.1 Third-party payment SDK — '$(basename "$payment_sdk")' detected; selling digital content/functionality must use in-app purchase, not an external processor (3.1.1). Allowed only for physical goods/services — verify your offering."
|
|
541
|
+
fi
|
|
542
|
+
fi
|
|
543
|
+
|
|
544
|
+
# ===================================================================
|
|
545
|
+
# §22 — 1.2 User-generated content without moderation affordances
|
|
546
|
+
# ===================================================================
|
|
547
|
+
# Apps with UGC must provide: a content filter, a report mechanism, the ability
|
|
548
|
+
# to block abusive users, and published contact info. We detect a UGC signal and
|
|
549
|
+
# warn when no report/block/moderation affordance is found anywhere in the source.
|
|
550
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
551
|
+
ugc_signal=$(grep -rlE 'userGeneratedContent|\bUGC\b|StreamChat|MessageKit|SendbirdSDK|PubNub|postComment|submitComment|createPost|publishPost|uploadUserPhoto|uploadVideo' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
552
|
+
if [[ -n "$ugc_signal" ]]; then
|
|
553
|
+
if grep -rqiE 'report(Content|User|Abuse|Post|Comment|Message|Reason|ed)|block(ed)?User|unblockUser|moderat(e|ion|or)|flag(Content|Post|User|Comment|Message)|content.?filter' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
554
|
+
pass "1.2 UGC — user-generated content with report/block/moderation affordances present"
|
|
555
|
+
else
|
|
556
|
+
warn "1.2 UGC — user-generated content detected (e.g. $(basename "$ugc_signal")) but no report/block/moderation mechanism found; UGC apps must offer content filtering, a report mechanism, user blocking, and published contact info (1.2)"
|
|
557
|
+
fi
|
|
558
|
+
fi
|
|
559
|
+
fi
|
|
560
|
+
|
|
561
|
+
# ===================================================================
|
|
562
|
+
# §23 — 1.6 App Transport Security disabled globally
|
|
563
|
+
# ===================================================================
|
|
564
|
+
# NSAllowsArbitraryLoads=true turns off ATS for the whole app, weakening
|
|
565
|
+
# data-in-transit security and inviting a 1.6 / data-security question at review.
|
|
566
|
+
if [[ -f "$INFO_PLIST" ]]; then
|
|
567
|
+
if awk '/NSAllowsArbitraryLoads/{getline; if ($0 ~ /<true/) print "ON"}' "$INFO_PLIST" 2>/dev/null | grep -q ON; then
|
|
568
|
+
warn "1.6 App Transport Security — NSAllowsArbitraryLoads=true in Info.plist disables ATS app-wide; prefer per-domain exceptions, and expect a data-security justification request at review (1.6)"
|
|
569
|
+
fi
|
|
570
|
+
fi
|
|
571
|
+
|
|
572
|
+
# ===================================================================
|
|
573
|
+
# §24 — 4.9 Apple Pay recurring-payment disclosure
|
|
574
|
+
# ===================================================================
|
|
575
|
+
# Apps using Apple Pay for recurring payments must disclose the renewal term,
|
|
576
|
+
# what's provided, the charges, and how to cancel. Gated strictly on the Apple
|
|
577
|
+
# Pay recurring API (PKRecurringPaymentRequest) so it does NOT conflate StoreKit
|
|
578
|
+
# auto-renew copy or a one-time PassKit payment with recurring Apple Pay. We don't
|
|
579
|
+
# try to detect the disclosure text (too noisy) — we flag it for manual verify.
|
|
580
|
+
if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
581
|
+
warn "4.9 Apple Pay — recurring Apple Pay (PKRecurringPaymentRequest) detected; verify you disclose the renewal term, what's provided, the charges, and how to cancel before purchase (4.9)"
|
|
582
|
+
fi
|
|
583
|
+
|
|
584
|
+
# ===================================================================
|
|
585
|
+
# §25 — 5.6.1 Custom App Store review prompt
|
|
586
|
+
# ===================================================================
|
|
587
|
+
# Apple disallows custom review prompts and direct write-review links — apps must
|
|
588
|
+
# use the system SKStoreReviewController / requestReview API.
|
|
589
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
590
|
+
review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
591
|
+
if [[ -n "$review_link" ]]; then
|
|
592
|
+
if grep -rqE 'requestReview|SKStoreReviewController|\.requestReview' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
593
|
+
pass "5.6.1 App reviews — uses the system requestReview API alongside the review link"
|
|
594
|
+
else
|
|
595
|
+
warn "5.6.1 App reviews — a direct App Store review link/prompt was found (e.g. $(basename "$review_link")) but no system requestReview (SKStoreReviewController) call; Apple disallows custom review prompts (5.6.1)"
|
|
596
|
+
fi
|
|
597
|
+
fi
|
|
598
|
+
fi
|
|
599
|
+
|
|
600
|
+
# ===================================================================
|
|
601
|
+
# §26 — 2.3.1 Misleading marketing claims in metadata
|
|
602
|
+
# ===================================================================
|
|
603
|
+
# Marketing the app for things iOS apps can't actually do (virus/malware
|
|
604
|
+
# scanners, fake speed boosters) is a 2.3.1 removal vector.
|
|
605
|
+
if [[ -d "$META_DIR" ]]; then
|
|
606
|
+
mislead_re='virus scan|virus scanner|antivirus|anti-virus|malware (scan|remov|clean)|spyware remov|clean your (iphone|device)|speed booster|boost.*(speed|ram)|free money|guaranteed.*(win|prize)'
|
|
607
|
+
mislead_hits=$(grep -rEniI "$mislead_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
608
|
+
if [[ -n "$mislead_hits" ]]; then
|
|
609
|
+
warn "2.3.1 Misleading marketing — claims that often violate 2.3.1 (e.g. iOS virus/malware scanners, fake speed boosters) in metadata; verify the app truly delivers them or remove the claim:"
|
|
610
|
+
echo "$mislead_hits" | sed 's/^/ /'
|
|
611
|
+
fi
|
|
612
|
+
fi
|
|
613
|
+
|
|
614
|
+
# ===================================================================
|
|
615
|
+
# §27 — 2.3.8 "For Kids/Children" wording outside the Kids Category
|
|
616
|
+
# ===================================================================
|
|
617
|
+
# Terms implying a child audience in name/subtitle/keywords/description are
|
|
618
|
+
# reserved for the Kids Category (2.3.8 / 5.1.4).
|
|
619
|
+
if [[ -d "$META_DIR" ]]; then
|
|
620
|
+
kids_re='for kids|for children|for your (kid|child)|kids[[:space:]]?app|für kinder|para niños|pour enfants'
|
|
621
|
+
kids_hits=$(grep -rEniI "$kids_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
622
|
+
if [[ -n "$kids_hits" ]]; then
|
|
623
|
+
warn "2.3.8 'For Kids/Children' wording — terms implying a child audience are reserved for the Kids Category (2.3.8); if not enrolled, remove them from name/subtitle/keywords/description:"
|
|
624
|
+
echo "$kids_hits" | sed 's/^/ /'
|
|
625
|
+
fi
|
|
626
|
+
fi
|
|
627
|
+
|
|
628
|
+
# ===================================================================
|
|
629
|
+
# §28 — 4.4.1 Keyboard extension requiring full access
|
|
630
|
+
# ===================================================================
|
|
631
|
+
# Keyboards must stay functional without full network access / "full access",
|
|
632
|
+
# and may only collect data to enhance the keyboard.
|
|
633
|
+
kb_plist=$(grep -rlE 'com\.apple\.keyboard-service' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
634
|
+
if [[ -n "$kb_plist" ]]; then
|
|
635
|
+
if grep -A1 'RequestsOpenAccess' "$kb_plist" 2>/dev/null | grep -q '<true'; then
|
|
636
|
+
warn "4.4.1 Keyboard extension — RequestsOpenAccess=true in $kb_plist; keyboards must remain functional without full access and may only collect data to enhance the keyboard (4.4.1). Justify full access in your review notes."
|
|
637
|
+
else
|
|
638
|
+
pass "4.4.1 Keyboard extension — present without requiring full access"
|
|
639
|
+
fi
|
|
640
|
+
fi
|
|
641
|
+
|
|
642
|
+
# ===================================================================
|
|
643
|
+
# §29 — 5.1.3 Health data with an iCloud sync path
|
|
644
|
+
# ===================================================================
|
|
645
|
+
# Personal health information must not be stored in iCloud, and HealthKit data
|
|
646
|
+
# may not be used for advertising/marketing.
|
|
647
|
+
if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
648
|
+
if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
649
|
+
warn "5.1.3 Health data — HealthKit and iCloud/CloudKit are both used; personal health information must not be stored in iCloud (5.1.3). Verify HealthKit data is not synced to iCloud."
|
|
650
|
+
else
|
|
651
|
+
pass "5.1.3 Health data — HealthKit used without an obvious iCloud sync path"
|
|
652
|
+
fi
|
|
653
|
+
fi
|
|
654
|
+
|
|
655
|
+
# ===================================================================
|
|
656
|
+
# §30 — 5.4 VPN apps (NetworkExtension)
|
|
657
|
+
# ===================================================================
|
|
658
|
+
# VPN apps must be offered by an organization account, declare data collection
|
|
659
|
+
# on-screen before use, and may not sell/share data.
|
|
660
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
661
|
+
vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
662
|
+
if [[ -n "$vpn_use" ]]; then
|
|
663
|
+
warn "5.4 VPN — NetworkExtension/NEVPNManager usage detected (e.g. $(basename "$vpn_use")); VPN apps must be offered by an organization account, disclose data collection on-screen before use, and not sell/share data (5.4). Verify compliance."
|
|
664
|
+
fi
|
|
665
|
+
fi
|
|
666
|
+
|
|
667
|
+
# ===================================================================
|
|
668
|
+
# §31 — 2.1 Demo account for a credential login
|
|
669
|
+
# ===================================================================
|
|
670
|
+
# Apps behind a login must give App Review working credentials (a demo account
|
|
671
|
+
# or notes). We fire only on a credential-login signal (a password field or a
|
|
672
|
+
# Login/SignIn view), then look for demo creds in fastlane review_information or
|
|
673
|
+
# the reviewer-prep notes. Social-only logins are not gated here (too noisy).
|
|
674
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
675
|
+
auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
676
|
+
[[ -z "$auth_signal" ]] && auth_signal=$(find "$IOS_DIR" "${PRUNE[@]}" \( -name '*Login*View*.swift' -o -name '*SignIn*View*.swift' \) 2>/dev/null | head -1)
|
|
677
|
+
if [[ -n "$auth_signal" ]]; then
|
|
678
|
+
demo_present=""
|
|
679
|
+
ri="$META_DIR/review_information"
|
|
680
|
+
if [[ -d "$ri" ]]; then
|
|
681
|
+
for f in demo_user.txt demo_password.txt notes.txt; do [[ -s "$ri/$f" ]] && demo_present=1; done
|
|
682
|
+
fi
|
|
683
|
+
if [[ -z "$demo_present" && -n "$REVIEW_PREP" && -f "$REVIEW_PREP" ]]; then
|
|
684
|
+
grep -qiE 'demo|review.*(account|credential)|test.*account' "$REVIEW_PREP" 2>/dev/null && demo_present=1
|
|
685
|
+
fi
|
|
686
|
+
if [[ -n "$demo_present" ]]; then
|
|
687
|
+
pass "2.1 Demo account — credential login present and reviewer demo credentials/notes found"
|
|
688
|
+
else
|
|
689
|
+
warn "2.1 Demo account — a credential login was detected (e.g. $(basename "$auth_signal")) but no demo account/credentials for App Review found (fastlane review_information or .reviewPrepNotes); apps behind a login must give reviewers working credentials (2.1)"
|
|
690
|
+
fi
|
|
691
|
+
fi
|
|
692
|
+
fi
|
|
693
|
+
|
|
694
|
+
# ===================================================================
|
|
695
|
+
# §32 — 2.5.2 Executable code download / hot-patch
|
|
696
|
+
# ===================================================================
|
|
697
|
+
# Apps must be self-contained; native hot-patching frameworks (JSPatch, Rollout,
|
|
698
|
+
# DynamicCocoa) download code that changes features and are a removal vector.
|
|
699
|
+
# JS-bundle OTA for React Native (CodePush) is allowed, so we do NOT flag it.
|
|
700
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
701
|
+
hotcode=$(grep -rlE 'import JSPatch|JSPatch\.|[Jj][Ss][Pp]atch|import Rollout|Rollout\.|rollout\.io|DynamicCocoa|import SwiftPatch' "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -1)
|
|
702
|
+
if [[ -n "$hotcode" ]]; then
|
|
703
|
+
warn "2.5.2 Executable code — a hot-patch / remote-code framework was detected (e.g. $(basename "$hotcode")); apps may not download or run code that changes features (JSPatch/Rollout-style native hot-patching). Allowed JS-bundle OTA (e.g. React Native CodePush) is fine — verify this is not native hot-patching (2.5.2)"
|
|
704
|
+
fi
|
|
705
|
+
fi
|
|
706
|
+
|
|
707
|
+
# ===================================================================
|
|
708
|
+
# §33 — 2.5.4 Background modes declared but unused
|
|
709
|
+
# ===================================================================
|
|
710
|
+
# Declare only the UIBackgroundModes the app actually uses; a mode declared with
|
|
711
|
+
# no matching API is a frequent rejection. We parse the array and check each
|
|
712
|
+
# declared mode against its framework/API in Swift.
|
|
713
|
+
if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
|
|
714
|
+
modes=$(awk '/<key>UIBackgroundModes<\/key>/{f=1;next} f&&/<\/array>/{f=0} f&&/<string>/{gsub(/.*<string>|<\/string>.*/,""); print}' "$INFO_PLIST" 2>/dev/null)
|
|
715
|
+
if [[ -n "$modes" ]]; then
|
|
716
|
+
unused=""
|
|
717
|
+
while IFS= read -r m; do
|
|
718
|
+
[[ -z "$m" ]] && continue
|
|
719
|
+
case "$m" in
|
|
720
|
+
location) grep -rqE 'CLLocationManager|CoreLocation' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused location" ;;
|
|
721
|
+
audio) grep -rqE 'AVAudioSession|AVPlayer|AVAudioPlayer|AVQueuePlayer|AVAudioEngine' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused audio" ;;
|
|
722
|
+
voip) grep -rqE 'PushKit|PKPushRegistry|CallKit|CXProvider' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused voip" ;;
|
|
723
|
+
fetch) grep -rqE 'BGAppRefreshTask|BackgroundTasks|setMinimumBackgroundFetchInterval|performFetchWithCompletionHandler' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused fetch" ;;
|
|
724
|
+
processing) grep -rqE 'BGProcessingTask|BackgroundTasks' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused processing" ;;
|
|
725
|
+
bluetooth-central|bluetooth-peripheral) grep -rqE 'CoreBluetooth|CBCentralManager|CBPeripheralManager' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused $m" ;;
|
|
726
|
+
remote-notification) grep -rqE 'didReceiveRemoteNotification|UNUserNotificationCenter|registerForRemoteNotifications' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused remote-notification" ;;
|
|
727
|
+
esac
|
|
728
|
+
done <<< "$modes"
|
|
729
|
+
if [[ -n "$unused" ]]; then
|
|
730
|
+
warn "2.5.4 Background modes —$unused declared in UIBackgroundModes but no matching API usage found in Swift; declare only the background modes the app actually uses (2.5.4)"
|
|
731
|
+
else
|
|
732
|
+
pass "2.5.4 Background modes — declared modes have matching API usage"
|
|
733
|
+
fi
|
|
734
|
+
fi
|
|
735
|
+
fi
|
|
736
|
+
|
|
737
|
+
# ===================================================================
|
|
738
|
+
# §34 — 3.1.5(a) Cryptocurrency wallet / exchange / mining
|
|
739
|
+
# ===================================================================
|
|
740
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
741
|
+
crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
742
|
+
if [[ -n "$crypto_sdk" ]]; then
|
|
743
|
+
warn "3.1.5(a) Cryptocurrency — a crypto wallet/exchange/mining signal was detected (e.g. $(basename "$crypto_sdk")); wallets & exchanges have entity and licensing requirements, and on-device mining is not permitted (3.1.5(a)). Verify eligibility."
|
|
744
|
+
fi
|
|
745
|
+
fi
|
|
746
|
+
|
|
747
|
+
# ===================================================================
|
|
748
|
+
# §35 — 4.2.3 Web-wrapper / thin app
|
|
749
|
+
# ===================================================================
|
|
750
|
+
# A thin WKWebView wrapper around a website is rejected under minimum
|
|
751
|
+
# functionality. Heuristic: WKWebView present in a project with very few Swift
|
|
752
|
+
# files. WARN (verify) — this is the most false-positive-prone of the batch.
|
|
753
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
754
|
+
if grep -rqE 'WKWebView' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
755
|
+
swift_n=$(find "$IOS_DIR" "${PRUNE[@]}" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')
|
|
756
|
+
if (( swift_n > 0 && swift_n <= 4 )); then
|
|
757
|
+
warn "4.2.3 Minimum functionality — the app appears to be a WKWebView wrapper with only $swift_n Swift file(s); a thin wrapper around a website is rejected under 4.2.3. Add native value, or verify this is a real app rather than a repackaged site."
|
|
758
|
+
fi
|
|
759
|
+
fi
|
|
760
|
+
fi
|
|
761
|
+
|
|
762
|
+
# ===================================================================
|
|
763
|
+
# §36 — 4.2.7 Remote desktop / host-mirroring
|
|
764
|
+
# ===================================================================
|
|
765
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
766
|
+
remote_desktop=$(grep -rlE 'import libvncclient|LibVNC|VNCClient|RDPSession|RDPKit|RemoteDesktopClient|import FreeRDP|JumpDesktop' "$IOS_DIR" --include="*.swift" --include="*.m" 2>/dev/null | head -1)
|
|
767
|
+
if [[ -n "$remote_desktop" ]]; then
|
|
768
|
+
warn "4.2.7 Remote desktop — a remote-desktop/mirroring signal was detected (e.g. $(basename "$remote_desktop")); host-mirroring apps must only show/control the owner's host, display host content (not App Store content), and be free or use IAP (4.2.7). Verify."
|
|
769
|
+
fi
|
|
770
|
+
fi
|
|
771
|
+
|
|
772
|
+
# ===================================================================
|
|
773
|
+
# §37 — 4.4.2 Safari extension / content blocker
|
|
774
|
+
# ===================================================================
|
|
775
|
+
safari_ext=$(grep -rlE 'com\.apple\.Safari\.(content-blocker|web-extension|extension)' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
776
|
+
if [[ -n "$safari_ext" ]]; then
|
|
777
|
+
warn "4.4.2 Safari extension — a Safari content-blocker / web extension was detected ($safari_ext); it must use the extension APIs as intended, do only what it declares, and not include hidden analytics/ads or track without consent (4.4.2). Verify."
|
|
778
|
+
fi
|
|
779
|
+
|
|
780
|
+
# ===================================================================
|
|
781
|
+
# §38 — 5.1.1(v) Account Sign-In: account creation without in-app deletion
|
|
782
|
+
# ===================================================================
|
|
783
|
+
# Apple 5.1.1(v): apps that support account creation must also let users delete
|
|
784
|
+
# their account from within the app. Detect account-creation signals, then look
|
|
785
|
+
# for an in-app deletion path. Deletion via a web page is missed → WARN, not FAIL.
|
|
786
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
787
|
+
signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
788
|
+
if [[ -n "$signup" ]]; then
|
|
789
|
+
if grep -rqiE 'delete.?account|account.?deletion|closeAccount|deleteUser|removeAccount|deleteMyAccount' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
790
|
+
pass "5.1.1(v) Account deletion — account creation present and an in-app account-deletion path was found"
|
|
791
|
+
else
|
|
792
|
+
warn "5.1.1(v) Account deletion — account creation was detected (e.g. $(basename "$signup")) but no in-app account-deletion path found; apps that support account creation must let users delete their account from within the app (5.1.1(v))"
|
|
793
|
+
fi
|
|
794
|
+
fi
|
|
795
|
+
fi
|
|
796
|
+
|
|
797
|
+
# ===================================================================
|
|
798
|
+
# §39 — 5.1.4 Kids audience with third-party ads / analytics
|
|
799
|
+
# ===================================================================
|
|
800
|
+
# Kids Category apps may not include third-party advertising or analytics and
|
|
801
|
+
# must include a parental gate. We fire when the metadata targets a child
|
|
802
|
+
# audience AND an ad/analytics SDK is linked. tracking_sdk/analytics_sdk come
|
|
803
|
+
# from §3/§19.
|
|
804
|
+
if [[ -d "$META_DIR" && -n "$IOS_DIR" ]]; then
|
|
805
|
+
if grep -rqEiI 'for kids|for children|kids[[:space:]]?app|für kinder|para niños|pour enfants' "$META_DIR" 2>/dev/null; then
|
|
806
|
+
if [[ -n "$tracking_sdk" || -n "$analytics_sdk" ]]; then
|
|
807
|
+
warn "5.1.4 Kids — the metadata targets a child audience and a third-party ads/analytics SDK is linked (e.g. $(basename "${tracking_sdk:-$analytics_sdk}")); Kids Category apps may not include third-party advertising or analytics and must offer a parental gate (5.1.4)"
|
|
808
|
+
fi
|
|
809
|
+
fi
|
|
810
|
+
fi
|
|
811
|
+
|
|
812
|
+
# ===================================================================
|
|
813
|
+
# §40 — 5.3.4 Real-money gambling
|
|
814
|
+
# ===================================================================
|
|
815
|
+
if [[ -d "$META_DIR" ]]; then
|
|
816
|
+
gamble_re='real[ -]?money|gambling|casino|sportsbook|sports[ -]?betting|place[ -].*bets|wager(ing)?|roulette.*real'
|
|
817
|
+
gamble_hits=$(grep -rEniI "$gamble_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
818
|
+
if [[ -n "$gamble_hits" ]]; then
|
|
819
|
+
warn "5.3.4 Gambling — real-money gaming language in metadata; real-money gambling/lotteries need the proper licenses, must be geo-restricted to permitted regions, and must be free on the App Store (5.3.4):"
|
|
820
|
+
echo "$gamble_hits" | sed 's/^/ /'
|
|
821
|
+
fi
|
|
822
|
+
fi
|
|
823
|
+
|
|
824
|
+
# ===================================================================
|
|
825
|
+
# §41 — 5.5 Mobile Device Management
|
|
826
|
+
# ===================================================================
|
|
827
|
+
if [[ -n "$IOS_DIR" ]]; then
|
|
828
|
+
mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
829
|
+
[[ -z "$mdm_sig" ]] && mdm_sig=$(grep -rlE 'com\.apple\.configuration\.managed' --include='*.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
830
|
+
if [[ -n "$mdm_sig" ]]; then
|
|
831
|
+
warn "5.5 MDM — a Mobile Device Management signal was detected (e.g. $(basename "$mdm_sig")); MDM apps require a commercial enterprise/education entity, may request the MDM capability only for that purpose, and must not sell or use the data for other ends (5.5). Verify eligibility."
|
|
832
|
+
fi
|
|
833
|
+
fi
|
|
834
|
+
|
|
513
835
|
echo "---END-OF-SCAN---"
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
# the .precheck-pass token action — so the verdict is machine-testable, not just an
|
|
6
6
|
# agent judgement.
|
|
7
7
|
#
|
|
8
|
-
# GREEN 0 FAIL and <=
|
|
9
|
-
# YELLOW 0 FAIL and >=
|
|
8
|
+
# GREEN 0 FAIL and <=4 WARN -> token: write
|
|
9
|
+
# YELLOW 0 FAIL and >=5 WARN -> token: hold (needs explicit human confirmation)
|
|
10
10
|
# RED >=1 FAIL -> token: remove
|
|
11
11
|
#
|
|
12
12
|
# Usage:
|
|
@@ -49,7 +49,7 @@ passes=$(count '^PASS:')
|
|
|
49
49
|
|
|
50
50
|
if (( fails >= 1 )); then
|
|
51
51
|
verdict="RED"; token="remove"; code=1
|
|
52
|
-
elif (( warns >=
|
|
52
|
+
elif (( warns >= 5 )); then
|
|
53
53
|
verdict="YELLOW"; token="hold"; code=2
|
|
54
54
|
else
|
|
55
55
|
verdict="GREEN"; token="write"; code=0
|