appstore-precheck 1.11.0 → 1.12.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 +87 -0
- package/README.md +65 -6
- package/bin/cli.js +6 -1
- package/package.json +2 -2
- package/skills/appstore-precheck/SKILL.md +40 -8
- package/skills/appstore-precheck/references/methodology.md +14 -0
- package/skills/appstore-precheck/references/pierre-deep-review.md +2 -2
- package/skills/appstore-precheck/references/simulator-dynamic-review.md +98 -0
- package/skills/appstore-precheck/scripts/findings.sh +6 -2
- package/skills/appstore-precheck/scripts/scan.sh +98 -69
- package/skills/appstore-precheck/scripts/suppress.sh +3 -2
- package/skills/appstore-precheck/scripts/thresholds.sh +12 -0
- package/skills/appstore-precheck/scripts/verdict.sh +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,93 @@ All notable changes to this project are documented here. Versioning follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.12.1] - 2026-07-08
|
|
9
|
+
|
|
10
|
+
Quality patch from a full fresh-eyes review (three independent review passes over the
|
|
11
|
+
scanner core, the tooling shell, and the docs). No new checks; every change either removes
|
|
12
|
+
a false-RED path, fixes a real bug, or hardens the release surface.
|
|
13
|
+
|
|
14
|
+
### Fixed (scanner accuracy — false-RED cluster)
|
|
15
|
+
- **3.1.2 Restore Purchases** no longer misses StoreKit 2 paywalls: the match is
|
|
16
|
+
case-insensitive and recognizes `AppStore.sync()` (a capitalized `Button("Restore
|
|
17
|
+
Purchases")` used to force a false RED).
|
|
18
|
+
- **3.1.2 Terms/Privacy links** match human-readable labels ("Terms of Use", "Privacy
|
|
19
|
+
Policy") and nonstandard URL paths (`/legal/tos`, `/eula`, `datenschutz`, `gizlilik`),
|
|
20
|
+
case-insensitively.
|
|
21
|
+
- **Remote-configured paywalls** (RevenueCatUI `PaywallView`, `presentPaywall`,
|
|
22
|
+
`paywallFooter`, AdaptyUI): missing Restore/Terms/Privacy in app source now downgrades
|
|
23
|
+
to a verify-in-dashboard WARN instead of a hard FAIL — the SDK renders those controls
|
|
24
|
+
from the vendor dashboard.
|
|
25
|
+
- **5.1.1 FileTimestamp Required Reason** check is anchored to real filesystem APIs;
|
|
26
|
+
plain `creationDate` / `modificationDate` model properties no longer false-FAIL.
|
|
27
|
+
|
|
28
|
+
### Fixed (coverage and correctness)
|
|
29
|
+
- **Objective-C blind spot closed**: all code-level greps now share one include-set
|
|
30
|
+
(`*.swift`, `*.m`, `*.mm`, `*.h`). Camera/mic/photo purpose-string FAILs, ATT/tracking
|
|
31
|
+
signals, SIWA, analytics and the other code checks now fire on ObjC sources too.
|
|
32
|
+
- **`--dir` is authoritative**: `scan.sh --dir <path>` (new flag) scans exactly that
|
|
33
|
+
directory instead of snapping to the enclosing git toplevel; the npx CLI passes it
|
|
34
|
+
through and the GitHub Action uses it, fixing monorepo-subdir scans and SARIF paths
|
|
35
|
+
(SARIF upload now also sets `checkout_path`).
|
|
36
|
+
- **Rule 42 suppressible**: `.precheck-ignore` accepted only rules 1–41; the bound is now
|
|
37
|
+
derived from the catalog, so `screenshot-dimensions` (and any future rule) can be
|
|
38
|
+
suppressed.
|
|
39
|
+
- **Verdict thresholds deduplicated** into `scripts/thresholds.sh`, shared by `verdict.sh`
|
|
40
|
+
and the JSON renderer so the two can never diverge.
|
|
41
|
+
- **CLI**: a signal-killed scanner now exits 70 instead of masquerading as success.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- Fixtures + assertions for every fix above (`storekit2-paywall-app`,
|
|
45
|
+
`revenuecat-paywall-app`, `objc-camera-app`, monorepo `--dir` case, rule-42 suppression),
|
|
46
|
+
all labelled in the synthetic scorecard corpus.
|
|
47
|
+
- **`tests/test-pack.sh`**: packs the real npm tarball and runs the CLI from the extracted
|
|
48
|
+
layout — a `files`-array regression can no longer ship silently.
|
|
49
|
+
|
|
50
|
+
### Changed (docs and CI hardening)
|
|
51
|
+
- README: CI example pins `@v1` (was stale `@v1.5.0`), "How it works" gains the opt-in
|
|
52
|
+
Phase 6 row and the screenshot-vision mention, new Troubleshooting section, Windows/exit-code
|
|
53
|
+
notes; coverage table reflects ObjC support.
|
|
54
|
+
- SKILL.md: Phase 6's Maestro MCP tools added to `allowed-tools`; Phase 2 gains an explicit
|
|
55
|
+
skip-without-credentials instruction; scanner paths phrased skill-relative; the Phase 4
|
|
56
|
+
summary separates the "+5 vision checks" from the "of 28" count; upload-guard hook note
|
|
57
|
+
scoped to plugin installs. `simulator-dynamic-review.md` now names the real Maestro MCP
|
|
58
|
+
tools (`list_devices`, `run`, `inspect_screen`, `take_screenshot`).
|
|
59
|
+
- Workflows: least-privilege `permissions:` blocks everywhere; all third-party actions
|
|
60
|
+
pinned to commit SHAs (also in `action.yml`).
|
|
61
|
+
- MAINTENANCE.md: stale "41 vectors" corrected; docs index expanded.
|
|
62
|
+
|
|
63
|
+
## [1.12.0] - 2026-07-02
|
|
64
|
+
|
|
65
|
+
Roadmap #5 (final roadmap item): an **optional, opt-in local dynamic simulator tier** —
|
|
66
|
+
agent-mode, advisory, and off by default. It never changes the verdict and never runs
|
|
67
|
+
in the offline CLI / npx / GitHub-Action path.
|
|
68
|
+
|
|
69
|
+
When the user explicitly asks for a dynamic check and supplies a built app (or a booted
|
|
70
|
+
simulator UDID + bundle id), the host model can run the app on a disposable simulator via
|
|
71
|
+
`xcrun simctl` + Maestro MCP tools and observe real behavior a static scan cannot — launch
|
|
72
|
+
without crashing (2.1), core screen reachable, paywall actually renders with terms (3.1.2),
|
|
73
|
+
permission prompt matches its `Info.plist` purpose string (5.1.1), a demo/login path works
|
|
74
|
+
(2.1), and live UI matches the marketing screenshots (2.3.5). It emits advisory
|
|
75
|
+
`DYNAMIC-PASS:` / `DYNAMIC-FINDING:` lines, exactly like Pierre deep-review's `REVIEW-*`
|
|
76
|
+
lines. It is the free/local alternative to a paid cloud device farm — a pre-submit local
|
|
77
|
+
smoke signal, **not** a TestFlight / crash-reporter / QA replacement.
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
- **`skills/appstore-precheck/references/simulator-dynamic-review.md`** — the 6-check
|
|
81
|
+
agent-mode dynamic checklist (D1–D6) with advisory output format, modeled on the existing
|
|
82
|
+
deep-review reference docs.
|
|
83
|
+
- **`SKILL.md`** — an optional "Phase 6" section (off by default) plus a scoped READ-ONLY
|
|
84
|
+
caveat and an updated "Known limits" note.
|
|
85
|
+
- Methodology + README notes describing the tier.
|
|
86
|
+
|
|
87
|
+
### Notes
|
|
88
|
+
- Documentation-only: the offline scan path (`scan.sh`/`verdict.sh`/`bin/cli.js`/`action.yml`)
|
|
89
|
+
is byte-identical. READ-ONLY preserved — the tier touches only disposable simulator state,
|
|
90
|
+
never the user's repo. It is permanently local-only (macOS + Xcode + a simulator; it cannot
|
|
91
|
+
run in this project's `ubuntu-latest` CI). A deterministic `simulator-smoke.sh` and authored
|
|
92
|
+
Maestro flows are deliberately deferred (simulator crash-timing is too flaky to ship as an
|
|
93
|
+
unvalidated deterministic check).
|
|
94
|
+
|
|
8
95
|
## [1.11.0] - 2026-07-02
|
|
9
96
|
|
|
10
97
|
Roadmap #3: **rejection-outcome feedback loop** — a third, honestly-scoped measurement
|
package/README.md
CHANGED
|
@@ -142,13 +142,14 @@ static scan already flagged a guideline, the deep check adds semantic context th
|
|
|
142
142
|
### Supported app types
|
|
143
143
|
|
|
144
144
|
Every check that reads store metadata, the privacy manifest, screenshots, or the export-compliance
|
|
145
|
-
key works on **any iOS app**. The code-level checks read Swift
|
|
145
|
+
key works on **any iOS app**. The code-level checks read Swift and Objective-C(++) source
|
|
146
|
+
(`.swift`, `.m`, `.mm`, `.h`), so their coverage depends on
|
|
146
147
|
how the app is built:
|
|
147
148
|
|
|
148
149
|
| App type | Coverage |
|
|
149
150
|
|----------|----------|
|
|
150
151
|
| 🟢 **Native Swift / SwiftUI** | **Full.** All 42 vectors apply. |
|
|
151
|
-
| 🟡 **React Native / Flutter** | Metadata, privacy manifest, screenshots, and export compliance apply in full. The
|
|
152
|
+
| 🟡 **React Native / Flutter** | Metadata, privacy manifest, screenshots, and export compliance apply in full. The native-source checks (ATT, paywall links, private API, SDK detection, navigation) **under-detect rather than misfire**: that logic lives in JS/Dart, so they stay quiet instead of blocking. |
|
|
152
153
|
|
|
153
154
|
## Quick start
|
|
154
155
|
|
|
@@ -237,6 +238,26 @@ npx appstore-precheck # scans the current directory, prints the verdi
|
|
|
237
238
|
npx appstore-precheck --fail-on YELLOW
|
|
238
239
|
```
|
|
239
240
|
|
|
241
|
+
**Homebrew** — same CLI, installed on PATH:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
brew install berkayturk/tap/appstore-precheck
|
|
245
|
+
appstore-precheck --dir path/to/your-ios-app
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**fastlane** — gate a lane on the verdict with the
|
|
249
|
+
[`appstore_precheck` plugin](https://github.com/berkayturk/fastlane-plugin-appstore_precheck)
|
|
250
|
+
([RubyGems](https://rubygems.org/gems/fastlane-plugin-appstore_precheck)):
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
# fastlane add_plugin appstore_precheck
|
|
254
|
+
lane :beta do
|
|
255
|
+
appstore_precheck # fails the lane on a RED verdict
|
|
256
|
+
build_app
|
|
257
|
+
upload_to_testflight
|
|
258
|
+
end
|
|
259
|
+
```
|
|
260
|
+
|
|
240
261
|
**Standalone Bash** — same static scan, run by hand or in CI:
|
|
241
262
|
|
|
242
263
|
```bash
|
|
@@ -255,7 +276,7 @@ Drop the static scan into a workflow with the bundled composite action. It fails
|
|
|
255
276
|
job on a RED verdict; set `fail-on: YELLOW` to be stricter:
|
|
256
277
|
|
|
257
278
|
```yaml
|
|
258
|
-
- uses: berkayturk/appstore-precheck@v1
|
|
279
|
+
- uses: berkayturk/appstore-precheck@v1
|
|
259
280
|
with:
|
|
260
281
|
working-directory: . # optional, default: . (repo root)
|
|
261
282
|
fail-on: RED # optional, default: RED (RED | YELLOW)
|
|
@@ -295,8 +316,9 @@ nothing is auto-fixed.
|
|
|
295
316
|
| **1** | **Static scan**: `scan.sh` over the 42 vectors above. |
|
|
296
317
|
| **2** | **`fastlane precheck`**: Apple's own metadata rule engine. |
|
|
297
318
|
| **3** | **Pierre commentary**: explains **every** FAIL and WARN from Phases 0–2 in 2–3 sentences each. |
|
|
298
|
-
| **4** | **Pierre deep review**: 28 semantic checks (22 Tier A + 6 Tier B v1 heuristic). Advisory only. |
|
|
319
|
+
| **4** | **Pierre deep review**: 28 semantic checks (22 Tier A + 6 Tier B v1 heuristic), plus 5 screenshot-vision checks when screenshots are present. Advisory only. |
|
|
299
320
|
| **5** | **Verdict**: GREEN / YELLOW / RED from Phases 0–2 counts, plus `.precheck-pass` token the upload guard gates on. |
|
|
321
|
+
| **6** | *(opt-in, agent mode)* **Local dynamic simulator tier**: launch/paywall/permission smoke checks on a local simulator via Maestro + `xcrun simctl`. Advisory; never changes the verdict. |
|
|
300
322
|
|
|
301
323
|
## Demo
|
|
302
324
|
|
|
@@ -350,6 +372,19 @@ and [Phase 2 `fastlane precheck`](examples/fastlane-precheck.md) results.
|
|
|
350
372
|
| 🟡 **YELLOW** | 0 FAIL, 5+ WARN | not written | blocked; needs confirmation |
|
|
351
373
|
| 🔴 **RED** | ≥1 FAIL | removed | blocked; shows the FAIL list |
|
|
352
374
|
|
|
375
|
+
### Badge — Prechecked by Pierre
|
|
376
|
+
|
|
377
|
+
Shipped a GREEN? Put Pierre in your README:
|
|
378
|
+
|
|
379
|
+
[](https://github.com/berkayturk/appstore-precheck)
|
|
380
|
+
|
|
381
|
+
```markdown
|
|
382
|
+
[](https://github.com/berkayturk/appstore-precheck)
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
The badge is self-declared (the scan is local and read-only, so there is nothing to verify
|
|
386
|
+
server-side) — it says "this project runs `appstore-precheck` before submitting", nothing more.
|
|
387
|
+
|
|
353
388
|
## Measured accuracy
|
|
354
389
|
|
|
355
390
|
[`docs/scorecard.md`](docs/scorecard.md) is generated by `scripts/scorecard.sh` and tracks two
|
|
@@ -431,6 +466,8 @@ App Store apps (DuckDuckGo, Pocket Casts, Wikipedia).
|
|
|
431
466
|
`bash`, `git`, `grep`, `find` are all the static scan, `npx appstore-precheck`, and the GitHub
|
|
432
467
|
Action need, and they run with **zero credentials and no network**. `jq` (config + String Catalog
|
|
433
468
|
checks) and `python3` (exact Unicode length counts) are optional and sharpen a few checks.
|
|
469
|
+
On Windows, run the CLI under WSL or Git Bash. CLI exit codes: `0` ok, `1` verdict at or past
|
|
470
|
+
`--fail-on`, `64` bad usage, `70` environment error (`bash` or the bundled scanner missing).
|
|
434
471
|
|
|
435
472
|
Only the **optional Phase 2** (`fastlane precheck`) needs `fastlane` and an
|
|
436
473
|
[App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api).
|
|
@@ -439,6 +476,26 @@ Everything else works without one.
|
|
|
439
476
|
**Secrets**: the ASC API key is read from your environment at runtime and deleted immediately after
|
|
440
477
|
`fastlane precheck`. Never commit it; `.gitignore` blocks `*asc-key*.json` and `.env`.
|
|
441
478
|
|
|
479
|
+
## Troubleshooting
|
|
480
|
+
|
|
481
|
+
- **Wrong source directory detected** (`PASS: layout — ios='…'` names the wrong folder): set
|
|
482
|
+
`.iosSourceDir` in `.appstore-precheck.json` (copy
|
|
483
|
+
[`config.example.json`](skills/appstore-precheck/config.example.json)). In a monorepo, pass
|
|
484
|
+
`--dir path/to/app` — an explicit `--dir` is authoritative and never snaps to the git root.
|
|
485
|
+
- **Unexpected YELLOW**: YELLOW means 5+ WARNs, no FAILs. Advisory checks (background modes,
|
|
486
|
+
tracking SDKs, screenshot sizes) can pile up on legitimate apps — review each WARN, then
|
|
487
|
+
suppress the accepted ones via [`.precheck-ignore`](#suppressing-findings-precheck-ignore)
|
|
488
|
+
so real signals stay visible.
|
|
489
|
+
- **Paywall FAILs on a remote-configured paywall** (RevenueCat dashboard paywalls and similar):
|
|
490
|
+
the scanner downgrades these to a verify-in-dashboard WARN when it sees `RevenueCatUI` /
|
|
491
|
+
`presentPaywall`. If your setup isn't detected, check the dashboard paywall manually and
|
|
492
|
+
suppress with `.precheck-ignore`.
|
|
493
|
+
- **`fastlane precheck` auth errors** (Phase 2): confirm the three `ASC_KEY_*` env vars are set
|
|
494
|
+
and the API key has App Manager access. Phase 2 is optional — skipping it only skips Apple's
|
|
495
|
+
metadata linter, the static verdict is unaffected.
|
|
496
|
+
- **`jq` or `python3` missing**: the scan still runs; config-file overrides, String Catalog
|
|
497
|
+
checks, and exact Unicode length counts degrade gracefully (noted in the output).
|
|
498
|
+
|
|
442
499
|
## Uninstall
|
|
443
500
|
|
|
444
501
|
```bash
|
|
@@ -468,8 +525,10 @@ CI runs ShellCheck, JSON validation, the version-consistency guard, and the fixt
|
|
|
468
525
|
|
|
469
526
|
This is a static heuristic tool plus Pierre's semantic deep review. A GREEN result **lowers but does
|
|
470
527
|
not eliminate** rejection risk; Apple's guidelines change frequently and reviewer decisions are
|
|
471
|
-
judgment calls. `REVIEW-FINDING` lines are advisory and do not block the token by themselves.
|
|
472
|
-
performs no runtime crash testing;
|
|
528
|
+
judgment calls. `REVIEW-FINDING` lines are advisory and do not block the token by themselves. The
|
|
529
|
+
default flow performs no runtime crash testing; an optional, opt-in local simulator tier
|
|
530
|
+
(Maestro and `xcrun simctl`) adds a pre-submit smoke signal but is not a TestFlight / crash-reporter
|
|
531
|
+
/ QA replacement. Always do a final manual review before you submit. Not affiliated
|
|
473
532
|
with Apple.
|
|
474
533
|
|
|
475
534
|
## Star History
|
package/bin/cli.js
CHANGED
|
@@ -91,7 +91,11 @@ function main() {
|
|
|
91
91
|
fail(`not a directory: ${opts.dir}`, 64);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
// --dir is passed through explicitly: scan.sh treats it as authoritative,
|
|
95
|
+
// so a monorepo subdirectory is scanned as requested instead of snapping to
|
|
96
|
+
// the enclosing git toplevel.
|
|
97
|
+
const scanArgs = [SCAN, '--dir', opts.dir];
|
|
98
|
+
if (opts.format !== 'text') scanArgs.push('--format', opts.format);
|
|
95
99
|
const scan = spawnSync('bash', scanArgs, {
|
|
96
100
|
cwd: opts.dir,
|
|
97
101
|
encoding: 'utf8',
|
|
@@ -101,6 +105,7 @@ function main() {
|
|
|
101
105
|
fail('bash is required to run the scanner (install bash, or use WSL / Git Bash on Windows)', 70);
|
|
102
106
|
}
|
|
103
107
|
if (scan.error) fail(`failed to run the scanner: ${scan.error.message}`, 70);
|
|
108
|
+
if (scan.signal) fail(`scanner was killed by signal ${scan.signal}`, 70);
|
|
104
109
|
|
|
105
110
|
const scanOut = scan.stdout || '';
|
|
106
111
|
process.stdout.write(scanOut);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appstore-precheck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.1",
|
|
4
4
|
"description": "Read-only iOS App Store pre-submission check, packaged as a portable Agent Skill with native Claude Code, Cursor, and Codex plugins, plus an npx CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Berkay Turk (https://github.com/berkayturk)",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"test": "bash tests/all.sh",
|
|
27
27
|
"check-versions": "bash scripts/check-versions.sh",
|
|
28
|
-
"lint": "node --check bin/cli.js && bash -n skills/appstore-precheck/scripts/scan.sh && bash -n skills/appstore-precheck/scripts/verdict.sh && bash -n skills/appstore-precheck/scripts/phase2-precheck.sh && bash -n skills/appstore-precheck/scripts/findings.sh && bash -n skills/appstore-precheck/scripts/suppress.sh && bash -n hooks/fastlane-guard.sh && bash -n install.sh && bash -n tests/run.sh && bash -n tests/all.sh && bash -n tests/test-verdict.sh && bash -n tests/test-guard.sh && bash -n tests/test-config.sh && bash -n tests/test-install.sh && bash -n tests/test-phase2.sh && bash -n tests/test-cli.sh && bash -n tests/test-findings.sh && bash -n tests/test-format-json.sh && bash -n tests/test-suppress.sh && bash -n tests/test-scorecard.sh && bash -n scripts/scorecard.sh && bash -n scripts/scorecard-real.sh"
|
|
28
|
+
"lint": "node --check bin/cli.js && bash -n skills/appstore-precheck/scripts/scan.sh && bash -n skills/appstore-precheck/scripts/verdict.sh && bash -n skills/appstore-precheck/scripts/phase2-precheck.sh && bash -n skills/appstore-precheck/scripts/findings.sh && bash -n skills/appstore-precheck/scripts/suppress.sh && bash -n hooks/fastlane-guard.sh && bash -n install.sh && bash -n tests/run.sh && bash -n tests/all.sh && bash -n tests/test-verdict.sh && bash -n tests/test-guard.sh && bash -n tests/test-config.sh && bash -n tests/test-install.sh && bash -n tests/test-phase2.sh && bash -n tests/test-cli.sh && bash -n tests/test-findings.sh && bash -n tests/test-format-json.sh && bash -n tests/test-suppress.sh && bash -n tests/test-scorecard.sh && bash -n scripts/scorecard.sh && bash -n scripts/scorecard-real.sh && bash -n scripts/update-brew-formula.sh && bash -n tests/test-pack.sh"
|
|
29
29
|
},
|
|
30
30
|
"keywords": ["ios", "app-store", "fastlane", "agent-skill", "claude-code", "precheck"]
|
|
31
31
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: appstore-precheck
|
|
3
|
-
description: Read-only pre-submission check for an iOS app before App Store review. Scans Swift code, fastlane metadata, screenshots, PrivacyInfo.xcprivacy, and the paywall for 42 rejection vectors, wraps Apple's official `fastlane precheck`, watches for live App Store Review Guideline drift, has Pierre explain every FAIL and WARN, then runs 22 semantic deep-review checks (Tier A) plus 6 heuristic checks (Tier B v1) — 28 total. Emits a GREEN/YELLOW/RED verdict and a `.precheck-pass` token an upload guard can gate on. Use when preparing an iOS App Store submission (before Archive, before "Submit for Review", before TestFlight, or before any `fastlane deliver/pilot/release`), or when the user mentions App Store rejection, app review, or fastlane upload.
|
|
3
|
+
description: Read-only pre-submission check for an iOS app before App Store review. Scans Swift and Objective-C code, fastlane metadata, screenshots, PrivacyInfo.xcprivacy, and the paywall for 42 rejection vectors, wraps Apple's official `fastlane precheck`, watches for live App Store Review Guideline drift, has Pierre explain every FAIL and WARN, then runs 22 semantic deep-review checks (Tier A) plus 6 heuristic checks (Tier B v1) — 28 total. Emits a GREEN/YELLOW/RED verdict and a `.precheck-pass` token an upload guard can gate on. Use when preparing an iOS App Store submission (before Archive, before "Submit for Review", before TestFlight, or before any `fastlane deliver/pilot/release`), or when the user mentions App Store rejection, app review, or fastlane upload.
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: Berkay Turk
|
|
7
|
-
version: 1.
|
|
8
|
-
allowed-tools: Bash Read Grep Glob WebFetch
|
|
7
|
+
version: 1.12.1
|
|
8
|
+
allowed-tools: Bash Read Grep Glob WebFetch mcp__maestro__list_devices mcp__maestro__run mcp__maestro__inspect_screen mcp__maestro__take_screenshot mcp__maestro__cheat_sheet
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# App Store Precheck
|
|
@@ -92,7 +92,7 @@ Rules:
|
|
|
92
92
|
**English** (Français + user lang, or just Français if they asked in French).
|
|
93
93
|
- Keep each one-liner short (one sentence). Vary wording each run; stay in Pierre's dry critic voice.
|
|
94
94
|
|
|
95
|
-
## Flow (
|
|
95
|
+
## Flow (Phases 0–5; plus an optional opt-in Phase 6)
|
|
96
96
|
|
|
97
97
|
### Phase 0: Live guideline drift check
|
|
98
98
|
|
|
@@ -112,7 +112,13 @@ check(s). It is non-blocking and never runs in the offline user scan; the finger
|
|
|
112
112
|
|
|
113
113
|
### Phase 1: Static scan
|
|
114
114
|
|
|
115
|
+
Run the bundled scanner from the app repo root. `scripts/` here is relative to THIS skill's own
|
|
116
|
+
directory (wherever the skill is installed — a plugin dir, `.claude/skills/`, `.agents/skills/`,
|
|
117
|
+
or a clone of the source repo):
|
|
118
|
+
|
|
115
119
|
```bash
|
|
120
|
+
bash <skill-dir>/scripts/scan.sh
|
|
121
|
+
# from a clone of the source repo, that is:
|
|
116
122
|
bash skills/appstore-precheck/scripts/scan.sh
|
|
117
123
|
```
|
|
118
124
|
|
|
@@ -151,7 +157,9 @@ output is unchanged.
|
|
|
151
157
|
### Phase 2: Apple's official `fastlane precheck`
|
|
152
158
|
|
|
153
159
|
Requires `bundleId` in config (or pass `app_identifier` directly) and App Store Connect API
|
|
154
|
-
credentials. **
|
|
160
|
+
credentials. **This phase is optional: if no ASC credentials are available, skip it, note the
|
|
161
|
+
skip in the report, and continue to Phase 3 — do not stall waiting for credentials.**
|
|
162
|
+
**Never commit the key.** The bundled wrapper builds the ASC API key JSON from your
|
|
155
163
|
environment, runs precheck, and deletes the key on exit (use `--dry-run` to preview the command
|
|
156
164
|
with no credentials and no network):
|
|
157
165
|
|
|
@@ -305,6 +313,8 @@ narrative; verdict.sh just pins the threshold arithmetic. `REVIEW-FINDING` lines
|
|
|
305
313
|
3. Present **Phase 3 commentary** — Pierre's 2–3 sentence explanation for every FAIL and WARN.
|
|
306
314
|
4. Present **Phase 4 deep review** — summary count (`REVIEW-FINDING` vs `REVIEW-PASS` of 28), then
|
|
307
315
|
every `REVIEW-FINDING` with Pierre explanation; list `REVIEW-PASS` lines compactly or omit if all 28 passed.
|
|
316
|
+
The 5 screenshot-vision checks (S1–S5) emit the same `REVIEW-*` prefixes but count as a
|
|
317
|
+
**separate "+5 vision checks" sub-block** in the summary, never inside the "of 28" denominator.
|
|
308
318
|
5. Present the **machine-faithful** scan output: each `FAIL:`/`WARN:` line verbatim, then for each
|
|
309
319
|
FAIL a `file:line` reference and a suggested fix (one line each, surgical, not paraphrased).
|
|
310
320
|
6. State the verdict and token action (example one-liners — each goes in its own language block, not inline):
|
|
@@ -314,9 +324,27 @@ narrative; verdict.sh just pins the threshold arithmetic. `REVIEW-FINDING` lines
|
|
|
314
324
|
7. Print the final manual checklist (see
|
|
315
325
|
[`references/methodology.md`](references/methodology.md#pre-submit-manual-checklist)).
|
|
316
326
|
|
|
327
|
+
### Phase 6: local dynamic simulator tier (optional, opt-in — off by default)
|
|
328
|
+
|
|
329
|
+
**This phase does not run by default.** Run it ONLY when the user explicitly asks for a dynamic /
|
|
330
|
+
simulator check AND supplies a built app (a simulator `.app` path, or a booted simulator UDID +
|
|
331
|
+
bundle id). It uses `xcrun simctl` + Maestro MCP tools (`mcp__maestro__*`) to launch the app on a
|
|
332
|
+
disposable simulator and observe real behavior — the free/local alternative to a paid cloud device
|
|
333
|
+
farm.
|
|
334
|
+
|
|
335
|
+
It emits advisory `DYNAMIC-PASS:` / `DYNAMIC-FINDING:` lines and **never changes the
|
|
336
|
+
GREEN/YELLOW/RED verdict** (the verdict stays derived only from Phases 0–2). It is read-only w.r.t.
|
|
337
|
+
the user's project — it touches only disposable simulator state, never the repo. It requires macOS +
|
|
338
|
+
Xcode + a simulator runtime and is permanently local-only (it cannot run in CI). It is a pre-submit
|
|
339
|
+
local smoke signal, not a TestFlight / crash-reporter / QA replacement.
|
|
340
|
+
|
|
341
|
+
Follow [`references/simulator-dynamic-review.md`](references/simulator-dynamic-review.md) for the
|
|
342
|
+
6-check dynamic checklist and output format.
|
|
343
|
+
|
|
317
344
|
## Rules
|
|
318
345
|
|
|
319
|
-
- **READ-ONLY:** never change code or assets. Only report and write the token.
|
|
346
|
+
- **READ-ONLY:** never change code or assets. Only report and write the token. (The optional Phase 6
|
|
347
|
+
simulator tier touches disposable simulator state only — never the user's project.)
|
|
320
348
|
- **Speed > exhaustiveness:** `scan.sh` uses parallel grep/jq and finishes in seconds.
|
|
321
349
|
- **No error swallowing:** if any scan command fails, that line is reported as FAIL and the scan continues.
|
|
322
350
|
- **Token location:** `.precheck-pass` at the repo root; the guard tests it with an `mmin -60` filter.
|
|
@@ -324,7 +352,9 @@ narrative; verdict.sh just pins the threshold arithmetic. `REVIEW-FINDING` lines
|
|
|
324
352
|
|
|
325
353
|
## Known limits
|
|
326
354
|
|
|
327
|
-
-
|
|
355
|
+
- The default flow is static analysis only. Runtime crash/behavior testing is available as an
|
|
356
|
+
optional, opt-in local simulator tier (Phase 6, `references/simulator-dynamic-review.md`); it is a
|
|
357
|
+
pre-submit local smoke signal, not a TestFlight / crash-reporter replacement.
|
|
328
358
|
- Several checks are advisory WARNs gated on detected signals (Sign in with Apple 4.8,
|
|
329
359
|
external-purchase 3.1.1(a), tracking/IDFA without ATT, analytics vs privacy manifest, metadata
|
|
330
360
|
URLs and placeholder copy). The export-compliance key is flagged when absent, but the actual
|
|
@@ -340,5 +370,7 @@ narrative; verdict.sh just pins the threshold arithmetic. `REVIEW-FINDING` lines
|
|
|
340
370
|
## Optional: upload guard hook
|
|
341
371
|
|
|
342
372
|
`hooks/fastlane-guard.sh` blocks `fastlane deliver/pilot/release` unless a fresh `.precheck-pass`
|
|
343
|
-
token exists. In Claude Code it auto-wires via `hooks/hooks.json
|
|
373
|
+
token exists. In Claude Code it auto-wires via `hooks/hooks.json` **when installed as a plugin**
|
|
374
|
+
(the hook path uses `${CLAUDE_PLUGIN_ROOT}`; an `install.sh`-vendored copy gets no automatic
|
|
375
|
+
hook). In other environments, wire it
|
|
344
376
|
as a pre-command check yourself, or treat the token as a manual go/no-go signal.
|
|
@@ -13,6 +13,7 @@ whole file to run the skill.
|
|
|
13
13
|
- [Verdict thresholds](#verdict-thresholds)
|
|
14
14
|
- [SARIF output](#sarif-output---format-sarif)
|
|
15
15
|
- [Real App Store outcomes](#real-app-store-outcomes-corpusoutcomes)
|
|
16
|
+
- [Optional local dynamic simulator tier](#optional-local-dynamic-simulator-tier)
|
|
16
17
|
- [Pre-submit manual checklist](#pre-submit-manual-checklist)
|
|
17
18
|
|
|
18
19
|
---
|
|
@@ -246,6 +247,19 @@ which rules fire. Contribute an outcome via the "App Store outcome" issue templa
|
|
|
246
247
|
|
|
247
248
|
---
|
|
248
249
|
|
|
250
|
+
### Optional local dynamic simulator tier
|
|
251
|
+
|
|
252
|
+
Beyond the static scan and the agent-mode deep reviews, an **opt-in** local dynamic tier
|
|
253
|
+
(`references/simulator-dynamic-review.md`, SKILL.md Phase 6) can run the app on a simulator
|
|
254
|
+
(`xcrun simctl` + Maestro MCP) and emit advisory `DYNAMIC-PASS:` / `DYNAMIC-FINDING:` observations —
|
|
255
|
+
launch/crash, paywall render, permission prompt vs purpose string, demo-login path, live UI vs
|
|
256
|
+
marketing screenshots. It is off by default, never changes the verdict, is read-only w.r.t. the
|
|
257
|
+
user's project (touches disposable simulator state only), and is permanently local-only (it cannot
|
|
258
|
+
run in CI). It is a pre-submit local smoke signal, not a TestFlight / crash-reporter / QA
|
|
259
|
+
replacement.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
249
263
|
## Pre-submit manual checklist
|
|
250
264
|
|
|
251
265
|
Things the scanner cannot verify; confirm by hand before you submit:
|
|
@@ -149,7 +149,7 @@ REVIEW-PASS: <guideline> — not applicable (<reason>)
|
|
|
149
149
|
3. Flag screenshots showing features absent from the build or metadata.
|
|
150
150
|
4. For the full structured screenshot vision review (placeholder/empty-state, text overflow,
|
|
151
151
|
wrong device frame, misleading marketing, metadata mismatch), follow
|
|
152
|
-
`
|
|
152
|
+
`screenshot-vision-review.md` (sibling file in this same `references/` directory).
|
|
153
153
|
|
|
154
154
|
### 9 — 2.3.6 Pricing language
|
|
155
155
|
|
|
@@ -271,6 +271,6 @@ After Phase 4, include in the final report:
|
|
|
271
271
|
|
|
272
272
|
1. Trilingual verdict block (from scan counts only).
|
|
273
273
|
2. Phase 3 commentary (every scan FAIL/WARN).
|
|
274
|
-
3. Phase 4 summary table: 28 checks → count of `REVIEW-FINDING` vs `REVIEW-PASS` (note Tier B items 4, 5, 7, 10, 15, 28 if any fired).
|
|
274
|
+
3. Phase 4 summary table: 28 checks → count of `REVIEW-FINDING` vs `REVIEW-PASS` (note Tier B items 4, 5, 7, 10, 15, 28 if any fired). The 5 screenshot-vision checks (S1–S5) report as a separate "+5 vision checks" sub-block, outside the "of 28" count.
|
|
275
275
|
4. Phase 4 detail: every `REVIEW-FINDING` with Pierre explanation; optionally list `REVIEW-PASS` lines compactly.
|
|
276
276
|
5. Verbatim Phase 1 scan output + verdict/token action.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Local dynamic simulator tier (agent-mode, opt-in, non-blocking)
|
|
2
|
+
|
|
3
|
+
The fourth, opt-in tier: a **local dynamic** review that runs the app on a simulator and observes
|
|
4
|
+
real behavior — the class of checks a static scan cannot make (does it launch without crashing, does
|
|
5
|
+
the paywall actually render, does a permission prompt match its purpose string, does a demo login
|
|
6
|
+
work, does the live UI match the marketing screenshots). It is the free/local alternative to a paid
|
|
7
|
+
cloud device farm, built from `xcrun simctl` + Maestro MCP tools (`mcp__maestro__*`) already
|
|
8
|
+
available on a Mac with Xcode.
|
|
9
|
+
|
|
10
|
+
**Identity:** runs ONLY in agent-skill mode, ONLY when the user explicitly asks for it and supplies a
|
|
11
|
+
built app (or a booted simulator UDID + bundle id). It is NOT part of the offline CLI / npx /
|
|
12
|
+
GitHub-Action scan, and it never runs by default. Requires macOS + Xcode + a simulator runtime; it
|
|
13
|
+
cannot run in this project's `ubuntu-latest` CI and is permanently local-only.
|
|
14
|
+
|
|
15
|
+
**This tier never changes the verdict.** GREEN/YELLOW/RED comes only from the static scan counts
|
|
16
|
+
(Phases 0–2). This tier emits advisory `DYNAMIC-PASS:` / `DYNAMIC-FINDING:` lines, like Pierre
|
|
17
|
+
deep-review's `REVIEW-*` lines.
|
|
18
|
+
|
|
19
|
+
**Read-only:** read-only w.r.t. the user's project — the tier installs/launches onto a **disposable
|
|
20
|
+
simulator** only and never modifies the user's repo. Prefer a dedicated/disposable simulator device;
|
|
21
|
+
do not disturb the user's everyday simulator state; leave teardown to the user unless the tier
|
|
22
|
+
created the device itself.
|
|
23
|
+
|
|
24
|
+
**Not a guarantee:** a pre-submit local smoke signal — NOT a replacement for TestFlight, a crash
|
|
25
|
+
reporter, or real-device QA.
|
|
26
|
+
|
|
27
|
+
## Rules
|
|
28
|
+
|
|
29
|
+
- Advisory only: every check reports `DYNAMIC-PASS:` or `DYNAMIC-FINDING:` — never `FAIL`, never a
|
|
30
|
+
verdict contribution.
|
|
31
|
+
- Evidence-based: cite a screenshot filename + what was observed. If a check cannot be driven (no
|
|
32
|
+
paywall in this app, a UI selector can't be found, no permissions declared), report
|
|
33
|
+
`DYNAMIC-PASS: … — not applicable` or `DYNAMIC-FINDING: … could not verify` — never invent a
|
|
34
|
+
failure.
|
|
35
|
+
- All 6 checks, every run: report each as PASS / FINDING / not-applicable.
|
|
36
|
+
- Boot a disposable simulator, launch the supplied app, and tear down cleanly.
|
|
37
|
+
- Write Pierre's 2–3 sentence explanations in the user's conversation language.
|
|
38
|
+
|
|
39
|
+
## The 6 checks
|
|
40
|
+
|
|
41
|
+
| # | Guideline | Dynamic question |
|
|
42
|
+
|---|-----------|------------------|
|
|
43
|
+
| D1 | **2.1** | Does the app launch on a fresh simulator and stay foregrounded briefly without crashing / a crash alert? |
|
|
44
|
+
| D2 | **2.1** | Does it reach a real first screen (not stuck on a splash, a blank screen, or an error alert)? |
|
|
45
|
+
| D3 | **3.1.2** | If a paywall/subscription exists, does it render with price + trial/auto-renew/terms visible on-screen (not just present as strings)? |
|
|
46
|
+
| D4 | **5.1.1(ii)/(iii)** | For each permission with an `Info.plist` purpose string, does the OS prompt appear at the right moment and match the declared string? |
|
|
47
|
+
| D5 | **2.1** | For a login-gated app, is there a reachable guest/demo path, or do the declared review demo credentials actually log in? |
|
|
48
|
+
| D6 | **2.3.5** | Do live screenshots of key screens match the submitted marketing screenshots (features shown match the running build)? |
|
|
49
|
+
|
|
50
|
+
## Per-check procedure
|
|
51
|
+
|
|
52
|
+
The Maestro MCP tools are: `mcp__maestro__list_devices` (pick a booted simulator's `device_id`),
|
|
53
|
+
`mcp__maestro__run` (execute a declarative YAML flow — `launchApp`, `tapOn`, `assertVisible`, …),
|
|
54
|
+
`mcp__maestro__inspect_screen` (view hierarchy), `mcp__maestro__take_screenshot`, and
|
|
55
|
+
`mcp__maestro__cheat_sheet` (YAML command reference). Every local tool needs a `device_id` from
|
|
56
|
+
`list_devices` first. `xcrun simctl` is the fallback when Maestro is unavailable.
|
|
57
|
+
|
|
58
|
+
### D1 — Launch without crash
|
|
59
|
+
1. `mcp__maestro__list_devices` for a booted disposable simulator (boot one with
|
|
60
|
+
`xcrun simctl boot` if needed); then `mcp__maestro__run` a flow that declares the `appId` and
|
|
61
|
+
starts with `launchApp` (fallback: `xcrun simctl launch`).
|
|
62
|
+
2. Observe for a short window; if the app process disappears or a crash alert shows, `DYNAMIC-FINDING`.
|
|
63
|
+
3. `mcp__maestro__take_screenshot` at launch as evidence.
|
|
64
|
+
|
|
65
|
+
### D2 — Core screen reachable
|
|
66
|
+
1. After launch, inspect the first real screen (`mcp__maestro__inspect_screen` /
|
|
67
|
+
`mcp__maestro__take_screenshot`).
|
|
68
|
+
2. Flag if stuck on a splash, blank, or an error/"something went wrong" state.
|
|
69
|
+
|
|
70
|
+
### D3 — Paywall renders
|
|
71
|
+
1. If a paywall exists (per the static scan / app structure), navigate to it with a
|
|
72
|
+
`mcp__maestro__run` flow (`tapOn` steps; check `mcp__maestro__cheat_sheet` for selector syntax).
|
|
73
|
+
2. Confirm price + trial/auto-renew/cancel terms are visibly rendered; screenshot. Not applicable if no paywall.
|
|
74
|
+
|
|
75
|
+
### D4 — Permission prompt vs purpose string
|
|
76
|
+
1. For each `NS*UsageDescription` in `Info.plist`, trigger the feature that requests it.
|
|
77
|
+
2. Confirm the OS prompt appears at the right moment and its text matches the declared purpose string; screenshot.
|
|
78
|
+
|
|
79
|
+
### D5 — Demo / login path
|
|
80
|
+
1. For a login-gated app, look for a guest/demo entry, or enter the declared review demo credentials.
|
|
81
|
+
2. Confirm a reachable path to core features; `DYNAMIC-FINDING` if the only path is a wall with no working demo.
|
|
82
|
+
|
|
83
|
+
### D6 — Live UI vs marketing screenshots
|
|
84
|
+
1. Capture live screenshots of key screens.
|
|
85
|
+
2. Compare to the submitted marketing screenshots; flag features shown in marketing but absent in the running build.
|
|
86
|
+
|
|
87
|
+
## Output format
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
DYNAMIC-PASS: <guideline> — <one-line why it looks OK, with screenshot filename>
|
|
91
|
+
```
|
|
92
|
+
or
|
|
93
|
+
```
|
|
94
|
+
DYNAMIC-FINDING: <guideline> — <one-line concrete issue, with screenshot filename>
|
|
95
|
+
Pierre: <2–3 sentences: why Apple cares, what you saw, what to fix or verify>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
End with a summary table: 6 checks → count of `DYNAMIC-FINDING` vs `DYNAMIC-PASS` (note any not-applicable).
|
|
@@ -72,15 +72,19 @@ _record_suppressed() {
|
|
|
72
72
|
# (RED >=1 FAIL; YELLOW >=5 WARN; else GREEN), counting non-suppressed findings only.
|
|
73
73
|
render_json() {
|
|
74
74
|
local buf="${FINDINGS_TMP:-/dev/null}"
|
|
75
|
+
# Thresholds live in thresholds.sh (shared with verdict.sh).
|
|
76
|
+
# shellcheck source=thresholds.sh
|
|
77
|
+
. "$(dirname "${BASH_SOURCE[0]}")/thresholds.sh"
|
|
75
78
|
[[ -s "$buf" ]] || { printf '%s\n' '{"findings":[]}' | jq \
|
|
76
79
|
--arg v "$PRECHECK_VERSION" '{tool:"appstore-precheck",version:$v,verdict:"GREEN",summary:{fail:0,warn:0,pass:0,suppressed:0},findings:[]}'; return 0; }
|
|
77
|
-
jq -s --arg v "$PRECHECK_VERSION"
|
|
80
|
+
jq -s --arg v "$PRECHECK_VERSION" \
|
|
81
|
+
--argjson fmin "$RED_FAIL_MIN" --argjson wmin "$YELLOW_WARN_MIN" '
|
|
78
82
|
(map(select(.suppressed==false))) as $live
|
|
79
83
|
| ($live|map(select(.severity=="FAIL"))|length) as $f
|
|
80
84
|
| ($live|map(select(.severity=="WARN"))|length) as $w
|
|
81
85
|
| ($live|map(select(.severity=="PASS"))|length) as $p
|
|
82
86
|
| (map(select(.suppressed==true))|length) as $s
|
|
83
|
-
| (if $f
|
|
87
|
+
| (if $f>=$fmin then "RED" elif $w>=$wmin then "YELLOW" else "GREEN" end) as $verdict
|
|
84
88
|
| {tool:"appstore-precheck", version:$v, verdict:$verdict,
|
|
85
89
|
summary:{fail:$f, warn:$w, pass:$p, suppressed:$s},
|
|
86
90
|
findings: .}' "$buf"
|
|
@@ -9,34 +9,49 @@
|
|
|
9
9
|
|
|
10
10
|
set -u
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Resolve the script dir to an absolute path BEFORE any cd, so the sibling
|
|
13
|
+
# sources below cannot break when an explicit --dir moves us elsewhere.
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
14
15
|
|
|
15
|
-
source "$(dirname "${BASH_SOURCE[0]}")/findings.sh"
|
|
16
|
-
source "$(dirname "${BASH_SOURCE[0]}")/suppress.sh"
|
|
17
|
-
source "$(dirname "${BASH_SOURCE[0]}")/project-model.sh"
|
|
18
|
-
source "$(dirname "${BASH_SOURCE[0]}")/image-dims.sh"
|
|
19
|
-
source "$(dirname "${BASH_SOURCE[0]}")/sarif.sh"
|
|
20
|
-
FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
|
|
21
|
-
trap 'rm -f "$FINDINGS_TMP"' EXIT
|
|
22
16
|
FORMAT="text"
|
|
23
|
-
|
|
24
|
-
# skill's SKILL.md), never the scanned repo's — $ROOT above is the SCANNED app's
|
|
25
|
-
# git root, so reading its package.json here would leak the wrong version.
|
|
26
|
-
PRECHECK_VERSION="$(grep -m1 -E '^[[:space:]]*version:' "$(dirname "${BASH_SOURCE[0]}")/../SKILL.md" 2>/dev/null | sed -E 's/.*version:[[:space:]]*//; s/[[:space:]]*$//')"
|
|
27
|
-
[[ -z "$PRECHECK_VERSION" ]] && PRECHECK_VERSION="dev"
|
|
28
|
-
|
|
17
|
+
SCAN_DIR=""
|
|
29
18
|
while [[ $# -gt 0 ]]; do
|
|
30
19
|
case "$1" in
|
|
31
20
|
--format)
|
|
32
21
|
if [[ $# -lt 2 ]]; then echo "scan.sh: --format needs a value (text|json|sarif)" >&2; exit 64; fi
|
|
33
22
|
FORMAT="$2"; shift 2 ;;
|
|
34
23
|
--format=*) FORMAT="${1#*=}"; shift ;;
|
|
24
|
+
--dir)
|
|
25
|
+
if [[ $# -lt 2 ]]; then echo "scan.sh: --dir needs a path" >&2; exit 64; fi
|
|
26
|
+
SCAN_DIR="$2"; shift 2 ;;
|
|
27
|
+
--dir=*) SCAN_DIR="${1#*=}"; shift ;;
|
|
35
28
|
*) shift ;;
|
|
36
29
|
esac
|
|
37
30
|
done
|
|
38
31
|
[[ "$FORMAT" == json || "$FORMAT" == text || "$FORMAT" == sarif ]] || { echo "scan.sh: --format must be text|json|sarif" >&2; exit 64; }
|
|
39
32
|
|
|
33
|
+
# An explicit --dir is authoritative: scan exactly that directory. Without it,
|
|
34
|
+
# snap to the enclosing git toplevel (monorepo subdirs need --dir to opt out).
|
|
35
|
+
if [[ -n "$SCAN_DIR" ]]; then
|
|
36
|
+
ROOT="$(cd "$SCAN_DIR" 2>/dev/null && pwd)" || { echo "scan.sh: --dir is not a directory: $SCAN_DIR" >&2; exit 64; }
|
|
37
|
+
else
|
|
38
|
+
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(pwd)"
|
|
39
|
+
fi
|
|
40
|
+
cd "$ROOT" || { echo "FAIL: repo-root — could not enter repository root"; exit 0; }
|
|
41
|
+
|
|
42
|
+
source "$SCRIPT_DIR/findings.sh"
|
|
43
|
+
source "$SCRIPT_DIR/suppress.sh"
|
|
44
|
+
source "$SCRIPT_DIR/project-model.sh"
|
|
45
|
+
source "$SCRIPT_DIR/image-dims.sh"
|
|
46
|
+
source "$SCRIPT_DIR/sarif.sh"
|
|
47
|
+
FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
|
|
48
|
+
trap 'rm -f "$FINDINGS_TMP"' EXIT
|
|
49
|
+
# The envelope `version` is the appstore-precheck TOOL's own version (from this
|
|
50
|
+
# skill's SKILL.md), never the scanned repo's — $ROOT above is the SCANNED app's
|
|
51
|
+
# git root, so reading its package.json here would leak the wrong version.
|
|
52
|
+
PRECHECK_VERSION="$(grep -m1 -E '^[[:space:]]*version:' "$SCRIPT_DIR/../SKILL.md" 2>/dev/null | sed -E 's/.*version:[[:space:]]*//; s/[[:space:]]*$//')"
|
|
53
|
+
[[ -z "$PRECHECK_VERSION" ]] && PRECHECK_VERSION="dev"
|
|
54
|
+
|
|
40
55
|
CONFIG="${APPSTORE_PRECHECK_CONFIG:-.appstore-precheck.json}"
|
|
41
56
|
have_jq() { command -v jq >/dev/null 2>&1; }
|
|
42
57
|
|
|
@@ -84,6 +99,11 @@ GREP_PRUNE=( --exclude-dir=.git --exclude-dir=Pods --exclude-dir=Carthage
|
|
|
84
99
|
--exclude-dir=.claude --exclude-dir=worktrees
|
|
85
100
|
--exclude-dir=node_modules --exclude-dir=vendor )
|
|
86
101
|
|
|
102
|
+
# One shared source include-set: Swift plus Objective-C(++). Every code-level
|
|
103
|
+
# grep uses this set — scanning *.swift only silently passes real rejection
|
|
104
|
+
# vectors (missing purpose strings, tracking SDKs) living in .m/.h files.
|
|
105
|
+
SRC_INC=( --include='*.swift' --include='*.m' --include='*.mm' --include='*.h' )
|
|
106
|
+
|
|
87
107
|
# Load .precheck-ignore path exclusions (Task 2 suppress.sh) and extend both
|
|
88
108
|
# prune sets so a suppressed path is skipped by detection AND grep passes.
|
|
89
109
|
load_precheck_ignore "$ROOT"
|
|
@@ -217,7 +237,7 @@ echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='
|
|
|
217
237
|
set_rule "privacy-manifest-parity"
|
|
218
238
|
check_required_reason_api() {
|
|
219
239
|
local cat="$1" pattern="$2" hits declared
|
|
220
|
-
hits=$(grep -rEl "$pattern" "$IOS_DIR"
|
|
240
|
+
hits=$(grep -rEl "$pattern" "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -3)
|
|
221
241
|
declared=$(grep -c "NSPrivacyAccessedAPICategory${cat}" "$PRIVACY_FILE" 2>/dev/null)
|
|
222
242
|
if [[ -n "$hits" && "${declared:-0}" -eq 0 ]]; then
|
|
223
243
|
fail "5.1.1 Required Reason API — '$cat' used in code (e.g. $(echo "$hits" | head -1)) but not declared in PrivacyInfo.xcprivacy" "$PRIVACY_FILE"
|
|
@@ -233,7 +253,9 @@ elif [[ -z "$PRIVACY_FILE" ]]; then
|
|
|
233
253
|
fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)" "$INFO_PLIST"
|
|
234
254
|
else
|
|
235
255
|
check_required_reason_api "UserDefaults" 'UserDefaults|@AppStorage'
|
|
236
|
-
|
|
256
|
+
# Anchored to real filesystem APIs: bare `creationDate` / `modificationDate`
|
|
257
|
+
# are ubiquitous model/Core-Data property names and false-FAIL on them.
|
|
258
|
+
check_required_reason_api "FileTimestamp" 'attributesOfItem|fileCreationDate|fileModificationDate|creationDateKey|contentModificationDateKey|contentModificationDate'
|
|
237
259
|
check_required_reason_api "SystemBootTime" 'systemUptime|mach_absolute_time|CACurrentMediaTime\(\)'
|
|
238
260
|
check_required_reason_api "DiskSpace" 'volumeAvailableCapacity|volumeTotalCapacity'
|
|
239
261
|
check_required_reason_api "ActiveKeyboard" 'activeInputModes|UITextInputMode'
|
|
@@ -255,7 +277,7 @@ else
|
|
|
255
277
|
"Contacts:NSContactsUsageDescription" \
|
|
256
278
|
"HealthKit:NSHealthShareUsageDescription"; do
|
|
257
279
|
framework="${fw%%:*}"; needed_key="${fw##*:}"
|
|
258
|
-
if grep -rqE "import ($framework)|($framework)\." "$IOS_DIR"
|
|
280
|
+
if grep -rqE "import ($framework)|($framework)\." "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
259
281
|
if ! grep -qE "$needed_key" "$INFO_PLIST" 2>/dev/null; then
|
|
260
282
|
fail "5.1.1 framework '${framework%%|*}' imported but Info.plist is missing '${needed_key%%|*}'" "$INFO_PLIST"
|
|
261
283
|
fi
|
|
@@ -267,7 +289,7 @@ else
|
|
|
267
289
|
# AVAudioPlayer/AVPlayer, and Photos/PhotosUI's PhotosPicker/PHPickerViewController
|
|
268
290
|
# run out-of-process and need no Info.plist key at all).
|
|
269
291
|
# Camera: purpose string required only when a capture API is actually used.
|
|
270
|
-
if grep -rqE 'AVCaptureDevice|AVCaptureSession|UIImagePickerController' "$IOS_DIR"
|
|
292
|
+
if grep -rqE 'AVCaptureDevice|AVCaptureSession|UIImagePickerController' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
271
293
|
grep -qE 'NSCameraUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
272
294
|
fail "5.1.1 camera capture API used but Info.plist is missing 'NSCameraUsageDescription'" "$INFO_PLIST"
|
|
273
295
|
fi
|
|
@@ -275,15 +297,15 @@ else
|
|
|
275
297
|
# separately from the camera check above: a bare AVCaptureDevice only
|
|
276
298
|
# implies microphone use when it is audio-typed (`.audio` device/media
|
|
277
299
|
# type), not for a video-only capture device.
|
|
278
|
-
if grep -rqE 'AVAudioRecorder|installTap\(|AVAudioEngine\(|AVAudioSession[^;]*\.(playAndRecord|record)|AVCaptureDevice[^;)]*\.audio|for:[[:space:]]*\.audio' "$IOS_DIR"
|
|
300
|
+
if grep -rqE 'AVAudioRecorder|installTap\(|AVAudioEngine\(|AVAudioSession[^;]*\.(playAndRecord|record)|AVCaptureDevice[^;)]*\.audio|for:[[:space:]]*\.audio' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
279
301
|
grep -qE 'NSMicrophoneUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
280
302
|
fail "5.1.1 microphone/recording API used but Info.plist is missing 'NSMicrophoneUsageDescription'" "$INFO_PLIST"
|
|
281
303
|
fi
|
|
282
304
|
# Photo library READ: PhotosPicker/PHPicker need no key; only true read/fetch APIs do.
|
|
283
|
-
if grep -rqE 'PHAsset\b|PHFetchResult|PHImageManager|fetchAssets|PHAssetCollection' "$IOS_DIR"
|
|
305
|
+
if grep -rqE 'PHAsset\b|PHFetchResult|PHImageManager|fetchAssets|PHAssetCollection' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
284
306
|
grep -qE 'NSPhotoLibraryUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
285
307
|
fail "5.1.1 Photos read API used but Info.plist is missing 'NSPhotoLibraryUsageDescription'" "$INFO_PLIST"
|
|
286
|
-
elif grep -rqE 'PHAssetCreationRequest|UIImageWriteToSavedPhotosAlbum|performChanges' "$IOS_DIR"
|
|
308
|
+
elif grep -rqE 'PHAssetCreationRequest|UIImageWriteToSavedPhotosAlbum|performChanges' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
287
309
|
# Add-only save: covered by the add-only key.
|
|
288
310
|
grep -qE 'NSPhotoLibraryAddUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
289
311
|
fail "5.1.1 Photos add-only API used but Info.plist is missing 'NSPhotoLibraryAddUsageDescription'" "$INFO_PLIST"
|
|
@@ -297,8 +319,8 @@ fi
|
|
|
297
319
|
# when the ATT framework IS imported; §16 fires when a tracking SDK is present but
|
|
298
320
|
# the ATT prompt is NOT. Computed once here so the two checks never contradict.
|
|
299
321
|
set_rule "att-usage"
|
|
300
|
-
tracking_sdk=$(grep -rlE 'advertisingIdentifier|ASIdentifierManager|GADMobileAds|GoogleMobileAds|AppLovinSDK|ALSdk|AppsFlyerLib|import Adjust|Adjust\.|FBAudienceNetwork|BranchSDK|IronSource' "$IOS_DIR"
|
|
301
|
-
att_used=$(grep -rlE 'AppTrackingTransparency|ATTrackingManager' "$IOS_DIR"
|
|
322
|
+
tracking_sdk=$(grep -rlE 'advertisingIdentifier|ASIdentifierManager|GADMobileAds|GoogleMobileAds|AppLovinSDK|ALSdk|AppsFlyerLib|import Adjust|Adjust\.|FBAudienceNetwork|BranchSDK|IronSource' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
323
|
+
att_used=$(grep -rlE 'AppTrackingTransparency|ATTrackingManager' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
302
324
|
if [[ -n "$att_used" ]]; then
|
|
303
325
|
if grep -q "NSUserTrackingUsageDescription" "$INFO_PLIST" 2>/dev/null; then
|
|
304
326
|
pass "5.1.2 ATT — used + NSUserTrackingUsageDescription present"
|
|
@@ -437,7 +459,7 @@ fi
|
|
|
437
459
|
# In-app purchase gate — only run 3.1.2 checks if IAP signals exist
|
|
438
460
|
# ===================================================================
|
|
439
461
|
iap_detected=""
|
|
440
|
-
grep -rqE 'SKPaymentQueue|SKProduct|SKMutablePayment|Product\.products|Product\(for:|Product\(id:|\.purchase\(|Transaction\.currentEntitlements|Transaction\.updates|RevenueCat|Purchases\.(shared|configure|logIn|getProducts)|Adapty|Glassfy|import Qonversion' "$IOS_DIR"
|
|
462
|
+
grep -rqE 'SKPaymentQueue|SKProduct|SKMutablePayment|Product\.products|Product\(for:|Product\(id:|\.purchase\(|Transaction\.currentEntitlements|Transaction\.updates|RevenueCat|Purchases\.(shared|configure|logIn|getProducts)|Adapty|Glassfy|import Qonversion' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null && iap_detected=1
|
|
441
463
|
[[ -n "$SUB_VIEW" ]] && iap_detected=1
|
|
442
464
|
|
|
443
465
|
if [[ -z "$iap_detected" ]]; then
|
|
@@ -483,21 +505,28 @@ else
|
|
|
483
505
|
# representative file in messages.
|
|
484
506
|
set_rule "subscription-links-restore"
|
|
485
507
|
if (( ${#PAYWALL_FILES[@]} > 0 )); then
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
508
|
+
# Remote-configured paywalls (RevenueCatUI's PaywallView, presentPaywall,
|
|
509
|
+
# paywallFooter, AdaptyUI) render Restore/Terms/Privacy from the vendor
|
|
510
|
+
# dashboard, so those controls never appear in app source. A hard FAIL here
|
|
511
|
+
# would false-RED a compliant app; downgrade the missing-link findings to
|
|
512
|
+
# verify-in-dashboard WARNs when that signal is present.
|
|
513
|
+
remote_paywall=""
|
|
514
|
+
grep -rqE 'RevenueCatUI|presentPaywall|paywallFooter|AdaptyUI' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null && remote_paywall=1
|
|
515
|
+
# paywall_req <label> <pattern> [pass-suffix]: PASS when the pattern is in
|
|
516
|
+
# any paywall view, WARN when a remote paywall likely renders it, else FAIL.
|
|
517
|
+
paywall_req() {
|
|
518
|
+
local label="$1" pattern="$2" pass_suffix="${3:-— present}"
|
|
519
|
+
if grep -qiE "$pattern" "${PAYWALL_FILES[@]}"; then
|
|
520
|
+
pass "3.1.2 $label $pass_suffix"
|
|
521
|
+
elif [[ -n "$remote_paywall" ]]; then
|
|
522
|
+
warn "3.1.2 $label — not in app source, but a remote-configured paywall (RevenueCatUI / presentPaywall) was detected; verify the dashboard-configured paywall shows it before submitting"
|
|
523
|
+
else
|
|
524
|
+
fail "3.1.2 $label — not found in the paywall views (e.g. $SUB_VIEW)"
|
|
525
|
+
fi
|
|
526
|
+
}
|
|
527
|
+
paywall_req "Restore Purchases" 'restore|AppStore\.sync' "— present in $(basename "$SUB_VIEW")"
|
|
528
|
+
paywall_req "Terms of Use (EULA) link" 'terms[ _]?of[ _]?(use|service)|termsURL|subscription_terms|EULA|/terms|/tos\b|/eula'
|
|
529
|
+
paywall_req "Privacy Policy link" 'privacy[ _]?policy|privacyURL|subscription_privacy|/privacy|datenschutz|gizlilik'
|
|
501
530
|
else
|
|
502
531
|
warn "3.1.2 IAP detected but no paywall/subscription view found — set .paywallGlobs so required-link checks can run"
|
|
503
532
|
fi
|
|
@@ -508,7 +537,7 @@ fi
|
|
|
508
537
|
# ===================================================================
|
|
509
538
|
set_rule "private-api"
|
|
510
539
|
banned_api='UIWebView|setSelectionIndicatorImage|_UIBackdropView|NSURLConnection[^a-zA-Z]|UIAlertView[^Q]|UIActionSheet[^Q]'
|
|
511
|
-
banned_hits=$(grep -rEnI "$banned_api" "$IOS_DIR"
|
|
540
|
+
banned_hits=$(grep -rEnI "$banned_api" "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -5)
|
|
512
541
|
if [[ -n "$banned_hits" ]]; then
|
|
513
542
|
pa_first="$(printf '%s\n' "$banned_hits" | head -1)" # "path:line:match"
|
|
514
543
|
pa_file="${pa_first%%:*}"
|
|
@@ -547,9 +576,9 @@ fi
|
|
|
547
576
|
# ===================================================================
|
|
548
577
|
set_rule "siwa-parity"
|
|
549
578
|
if [[ -n "$IOS_DIR" ]]; then
|
|
550
|
-
social_login=$(grep -rlE 'GIDSignIn|import GoogleSignIn|FBSDKLoginKit|FBSDKLoginManager|FacebookLogin|import Auth0|LineSDK|VKSdkAuthorization' "$IOS_DIR"
|
|
579
|
+
social_login=$(grep -rlE 'GIDSignIn|import GoogleSignIn|FBSDKLoginKit|FBSDKLoginManager|FacebookLogin|import Auth0|LineSDK|VKSdkAuthorization' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
551
580
|
if [[ -n "$social_login" ]]; then
|
|
552
|
-
if grep -rqE 'ASAuthorizationAppleID|SignInWithAppleButton|AppleIDProvider' "$IOS_DIR"
|
|
581
|
+
if grep -rqE 'ASAuthorizationAppleID|SignInWithAppleButton|AppleIDProvider' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
553
582
|
pass "4.8 Sign in with Apple — present alongside third-party login"
|
|
554
583
|
else
|
|
555
584
|
warn "4.8 Sign in with Apple — third-party social login detected (e.g. $(basename "$social_login")) but no Sign in with Apple found; Apple requires an equivalent option (4.8). Some account systems are exempt — verify."
|
|
@@ -562,7 +591,7 @@ fi
|
|
|
562
591
|
# ===================================================================
|
|
563
592
|
set_rule "external-purchase-link"
|
|
564
593
|
ext_purchase=""
|
|
565
|
-
grep -rqE 'ExternalPurchase|ExternalPurchaseLink|ExternalPurchaseCustomLink' "${IOS_DIR:-.}"
|
|
594
|
+
grep -rqE 'ExternalPurchase|ExternalPurchaseLink|ExternalPurchaseCustomLink' "${IOS_DIR:-.}" "${SRC_INC[@]}" 2>/dev/null && ext_purchase=1
|
|
566
595
|
grep -rqE 'external-purchase' "${IOS_DIR:-.}" --include='*.entitlements' 2>/dev/null && ext_purchase=1
|
|
567
596
|
if [[ -n "$ext_purchase" ]]; then
|
|
568
597
|
warn "3.1.1(a) External purchase link detected — ensure the External Purchase entitlement, eligible storefronts, the required disclosure sheet, and App Store Connect reporting are in place (3.1.1(a))."
|
|
@@ -637,7 +666,7 @@ fi
|
|
|
637
666
|
# incomplete. Soft "verify" wording (a crash-only Sentry may genuinely collect
|
|
638
667
|
# nothing), so WARN, never FAIL.
|
|
639
668
|
set_rule "analytics-privacyinfo-mismatch"
|
|
640
|
-
analytics_sdk=$(grep -rlE 'FirebaseAnalytics|import Firebase|import Amplitude|Amplitude\(|import Mixpanel|Mixpanel\.|import Sentry|SentrySDK|import Segment|SEGAnalytics|Analytics\.shared\(|import Bugsnag|Bugsnag\.|AppCenterAnalytics|import Datadog|DatadogCore' "$IOS_DIR"
|
|
669
|
+
analytics_sdk=$(grep -rlE 'FirebaseAnalytics|import Firebase|import Amplitude|Amplitude\(|import Mixpanel|Mixpanel\.|import Sentry|SentrySDK|import Segment|SEGAnalytics|Analytics\.shared\(|import Bugsnag|Bugsnag\.|AppCenterAnalytics|import Datadog|DatadogCore' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
641
670
|
if [[ -n "$analytics_sdk" ]]; then
|
|
642
671
|
declared_data=""
|
|
643
672
|
if [[ -n "$PRIVACY_FILE" && -f "$PRIVACY_FILE" ]]; then
|
|
@@ -680,7 +709,7 @@ fi
|
|
|
680
709
|
# content. We can't tell digital from physical statically, so this is advisory.
|
|
681
710
|
set_rule "thirdparty-payment-sdk"
|
|
682
711
|
if [[ -n "$IOS_DIR" ]]; then
|
|
683
|
-
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"
|
|
712
|
+
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" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
684
713
|
if [[ -n "$payment_sdk" ]]; then
|
|
685
714
|
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."
|
|
686
715
|
fi
|
|
@@ -694,9 +723,9 @@ fi
|
|
|
694
723
|
# warn when no report/block/moderation affordance is found anywhere in the source.
|
|
695
724
|
set_rule "ugc-no-moderation"
|
|
696
725
|
if [[ -n "$IOS_DIR" ]]; then
|
|
697
|
-
ugc_signal=$(grep -rlE 'userGeneratedContent|\bUGC\b|StreamChat|MessageKit|SendbirdSDK|PubNub|postComment|submitComment|createPost|publishPost|uploadUserPhoto|uploadVideo' "$IOS_DIR"
|
|
726
|
+
ugc_signal=$(grep -rlE 'userGeneratedContent|\bUGC\b|StreamChat|MessageKit|SendbirdSDK|PubNub|postComment|submitComment|createPost|publishPost|uploadUserPhoto|uploadVideo' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
698
727
|
if [[ -n "$ugc_signal" ]]; then
|
|
699
|
-
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"
|
|
728
|
+
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" "${SRC_INC[@]}" 2>/dev/null; then
|
|
700
729
|
pass "1.2 UGC — user-generated content with report/block/moderation affordances present"
|
|
701
730
|
else
|
|
702
731
|
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)"
|
|
@@ -728,7 +757,7 @@ fi
|
|
|
728
757
|
# auto-renew copy or a one-time PassKit payment with recurring Apple Pay. We don't
|
|
729
758
|
# try to detect the disclosure text (too noisy) — we flag it for manual verify.
|
|
730
759
|
set_rule "applepay-recurring-disclosure"
|
|
731
|
-
if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR"
|
|
760
|
+
if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
732
761
|
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)"
|
|
733
762
|
fi
|
|
734
763
|
|
|
@@ -739,9 +768,9 @@ fi
|
|
|
739
768
|
# use the system SKStoreReviewController / requestReview API.
|
|
740
769
|
set_rule "custom-review-prompt"
|
|
741
770
|
if [[ -n "$IOS_DIR" ]]; then
|
|
742
|
-
review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR"
|
|
771
|
+
review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
743
772
|
if [[ -n "$review_link" ]]; then
|
|
744
|
-
if grep -rqE 'requestReview|SKStoreReviewController|\.requestReview' "$IOS_DIR"
|
|
773
|
+
if grep -rqE 'requestReview|SKStoreReviewController|\.requestReview' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
745
774
|
pass "5.6.1 App reviews — uses the system requestReview API alongside the review link"
|
|
746
775
|
else
|
|
747
776
|
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)"
|
|
@@ -805,8 +834,8 @@ fi
|
|
|
805
834
|
# Personal health information must not be stored in iCloud, and HealthKit data
|
|
806
835
|
# may not be used for advertising/marketing.
|
|
807
836
|
set_rule "health-icloud-sync"
|
|
808
|
-
if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR"
|
|
809
|
-
if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR"
|
|
837
|
+
if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
838
|
+
if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
810
839
|
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."
|
|
811
840
|
else
|
|
812
841
|
pass "5.1.3 Health data — HealthKit used without an obvious iCloud sync path"
|
|
@@ -820,7 +849,7 @@ fi
|
|
|
820
849
|
# on-screen before use, and may not sell/share data.
|
|
821
850
|
set_rule "vpn-networkextension"
|
|
822
851
|
if [[ -n "$IOS_DIR" ]]; then
|
|
823
|
-
vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR"
|
|
852
|
+
vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
824
853
|
if [[ -n "$vpn_use" ]]; then
|
|
825
854
|
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."
|
|
826
855
|
fi
|
|
@@ -835,7 +864,7 @@ fi
|
|
|
835
864
|
# the reviewer-prep notes. Social-only logins are not gated here (too noisy).
|
|
836
865
|
set_rule "demo-account"
|
|
837
866
|
if [[ -n "$IOS_DIR" ]]; then
|
|
838
|
-
auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR"
|
|
867
|
+
auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
839
868
|
[[ -z "$auth_signal" ]] && auth_signal=$(find "$IOS_DIR" "${PRUNE[@]}" \( -name '*Login*View*.swift' -o -name '*SignIn*View*.swift' \) 2>/dev/null | head -1)
|
|
840
869
|
if [[ -n "$auth_signal" ]]; then
|
|
841
870
|
demo_present=""
|
|
@@ -862,7 +891,7 @@ fi
|
|
|
862
891
|
# JS-bundle OTA for React Native (CodePush) is allowed, so we do NOT flag it.
|
|
863
892
|
set_rule "executable-code-download"
|
|
864
893
|
if [[ -n "$IOS_DIR" ]]; then
|
|
865
|
-
hotcode=$(grep -rlE 'import JSPatch|JSPatch\.|[Jj][Ss][Pp]atch|import Rollout|Rollout\.|rollout\.io|DynamicCocoa|import SwiftPatch' "$IOS_DIR"
|
|
894
|
+
hotcode=$(grep -rlE 'import JSPatch|JSPatch\.|[Jj][Ss][Pp]atch|import Rollout|Rollout\.|rollout\.io|DynamicCocoa|import SwiftPatch' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
866
895
|
if [[ -n "$hotcode" ]]; then
|
|
867
896
|
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)"
|
|
868
897
|
fi
|
|
@@ -882,13 +911,13 @@ if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
|
|
|
882
911
|
while IFS= read -r m; do
|
|
883
912
|
[[ -z "$m" ]] && continue
|
|
884
913
|
case "$m" in
|
|
885
|
-
location) grep -rqE 'CLLocationManager|CoreLocation' "$IOS_DIR"
|
|
886
|
-
audio) grep -rqE 'AVAudioSession|AVPlayer|AVAudioPlayer|AVQueuePlayer|AVAudioEngine|AVKit|VideoPlayer|AVPlayerViewController|MPMusicPlayerController|MPNowPlayingInfoCenter' "$IOS_DIR"
|
|
887
|
-
voip) grep -rqE 'PushKit|PKPushRegistry|CallKit|CXProvider' "$IOS_DIR"
|
|
888
|
-
fetch) grep -rqE 'BGAppRefreshTask|BGTaskScheduler|BackgroundTasks|setMinimumBackgroundFetchInterval|performFetchWithCompletionHandler' "$IOS_DIR"
|
|
889
|
-
processing) grep -rqE 'BGProcessingTask|BGTaskScheduler|BackgroundTasks' "$IOS_DIR"
|
|
890
|
-
bluetooth-central|bluetooth-peripheral) grep -rqE 'CoreBluetooth|CBCentralManager|CBPeripheralManager' "$IOS_DIR"
|
|
891
|
-
remote-notification) grep -rqE 'didReceiveRemoteNotification|UNUserNotificationCenter|registerForRemoteNotifications' "$IOS_DIR"
|
|
914
|
+
location) grep -rqE 'CLLocationManager|CoreLocation' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused location" ;;
|
|
915
|
+
audio) grep -rqE 'AVAudioSession|AVPlayer|AVAudioPlayer|AVQueuePlayer|AVAudioEngine|AVKit|VideoPlayer|AVPlayerViewController|MPMusicPlayerController|MPNowPlayingInfoCenter' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused audio" ;;
|
|
916
|
+
voip) grep -rqE 'PushKit|PKPushRegistry|CallKit|CXProvider' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused voip" ;;
|
|
917
|
+
fetch) grep -rqE 'BGAppRefreshTask|BGTaskScheduler|BackgroundTasks|setMinimumBackgroundFetchInterval|performFetchWithCompletionHandler' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused fetch" ;;
|
|
918
|
+
processing) grep -rqE 'BGProcessingTask|BGTaskScheduler|BackgroundTasks' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused processing" ;;
|
|
919
|
+
bluetooth-central|bluetooth-peripheral) grep -rqE 'CoreBluetooth|CBCentralManager|CBPeripheralManager' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused $m" ;;
|
|
920
|
+
remote-notification) grep -rqE 'didReceiveRemoteNotification|UNUserNotificationCenter|registerForRemoteNotifications' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null || unused="$unused remote-notification" ;;
|
|
892
921
|
esac
|
|
893
922
|
done <<< "$modes"
|
|
894
923
|
if [[ -n "$unused" ]]; then
|
|
@@ -904,7 +933,7 @@ fi
|
|
|
904
933
|
# ===================================================================
|
|
905
934
|
set_rule "crypto-wallet-mining"
|
|
906
935
|
if [[ -n "$IOS_DIR" ]]; then
|
|
907
|
-
crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR"
|
|
936
|
+
crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
908
937
|
if [[ -n "$crypto_sdk" ]]; then
|
|
909
938
|
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."
|
|
910
939
|
fi
|
|
@@ -918,7 +947,7 @@ fi
|
|
|
918
947
|
# files. WARN (verify) — this is the most false-positive-prone of the batch.
|
|
919
948
|
set_rule "webview-wrapper"
|
|
920
949
|
if [[ -n "$IOS_DIR" ]]; then
|
|
921
|
-
if grep -rqE 'WKWebView' "$IOS_DIR"
|
|
950
|
+
if grep -rqE 'WKWebView' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
922
951
|
swift_n=$(find "$IOS_DIR" "${PRUNE[@]}" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')
|
|
923
952
|
if (( swift_n > 0 && swift_n <= 4 )); then
|
|
924
953
|
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."
|
|
@@ -931,7 +960,7 @@ fi
|
|
|
931
960
|
# ===================================================================
|
|
932
961
|
set_rule "remote-desktop"
|
|
933
962
|
if [[ -n "$IOS_DIR" ]]; then
|
|
934
|
-
remote_desktop=$(grep -rlE 'import libvncclient|LibVNC|VNCClient|RDPSession|RDPKit|RemoteDesktopClient|import FreeRDP|JumpDesktop' "$IOS_DIR"
|
|
963
|
+
remote_desktop=$(grep -rlE 'import libvncclient|LibVNC|VNCClient|RDPSession|RDPKit|RemoteDesktopClient|import FreeRDP|JumpDesktop' "$IOS_DIR" "${SRC_INC[@]}" --include="*.m" 2>/dev/null | head -1)
|
|
935
964
|
if [[ -n "$remote_desktop" ]]; then
|
|
936
965
|
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."
|
|
937
966
|
fi
|
|
@@ -954,9 +983,9 @@ fi
|
|
|
954
983
|
# for an in-app deletion path. Deletion via a web page is missed → WARN, not FAIL.
|
|
955
984
|
set_rule "account-no-delete"
|
|
956
985
|
if [[ -n "$IOS_DIR" ]]; then
|
|
957
|
-
signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR"
|
|
986
|
+
signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
958
987
|
if [[ -n "$signup" ]]; then
|
|
959
|
-
if grep -rqiE 'delete.?account|account.?deletion|closeAccount|deleteUser|removeAccount|deleteMyAccount' "$IOS_DIR"
|
|
988
|
+
if grep -rqiE 'delete.?account|account.?deletion|closeAccount|deleteUser|removeAccount|deleteMyAccount' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null; then
|
|
960
989
|
pass "5.1.1(v) Account deletion — account creation present and an in-app account-deletion path was found"
|
|
961
990
|
else
|
|
962
991
|
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))"
|
|
@@ -1001,7 +1030,7 @@ fi
|
|
|
1001
1030
|
# ===================================================================
|
|
1002
1031
|
set_rule "mdm"
|
|
1003
1032
|
if [[ -n "$IOS_DIR" ]]; then
|
|
1004
|
-
mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR"
|
|
1033
|
+
mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR" "${SRC_INC[@]}" 2>/dev/null | head -1)
|
|
1005
1034
|
[[ -z "$mdm_sig" ]] && mdm_sig=$(grep -rlE 'com\.apple\.configuration\.managed' --include='*.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
1006
1035
|
if [[ -n "$mdm_sig" ]]; then
|
|
1007
1036
|
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."
|
|
@@ -8,11 +8,12 @@ _SUPP_PATHS="" # path globs excluded from scanning, one per line (trailin
|
|
|
8
8
|
_SUPP_REASON="" # set by is_suppressed on a hit
|
|
9
9
|
|
|
10
10
|
# _is_catalog_rule <token> -> 0 if token is a known rule slug.
|
|
11
|
+
# The bound is derived from the catalog itself (rule_slug returns "" past the
|
|
12
|
+
# last rule), so a newly added rule can never be silently unsuppressable again.
|
|
11
13
|
_is_catalog_rule() {
|
|
12
14
|
local n s
|
|
13
15
|
n=1
|
|
14
|
-
while
|
|
15
|
-
s="$(rule_slug "$n")"
|
|
16
|
+
while s="$(rule_slug "$n")" && [[ -n "$s" ]]; do
|
|
16
17
|
[[ "$s" == "$1" ]] && return 0
|
|
17
18
|
n=$((n + 1))
|
|
18
19
|
done
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# thresholds.sh — the single source of truth for the verdict thresholds.
|
|
3
|
+
# Sourced by verdict.sh and findings.sh so the two renderers can never diverge.
|
|
4
|
+
#
|
|
5
|
+
# RED >= RED_FAIL_MIN FAILs
|
|
6
|
+
# YELLOW >= YELLOW_WARN_MIN WARNs (and no FAIL)
|
|
7
|
+
# GREEN otherwise
|
|
8
|
+
|
|
9
|
+
# shellcheck disable=SC2034 # consumed by the sourcing scripts, not here
|
|
10
|
+
RED_FAIL_MIN=1
|
|
11
|
+
# shellcheck disable=SC2034
|
|
12
|
+
YELLOW_WARN_MIN=5
|
|
@@ -47,9 +47,13 @@ fails=$(count '^FAIL:')
|
|
|
47
47
|
warns=$(count '^WARN:')
|
|
48
48
|
passes=$(count '^PASS:')
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
# Thresholds live in thresholds.sh (shared with findings.sh's JSON renderer).
|
|
51
|
+
# shellcheck source=thresholds.sh
|
|
52
|
+
. "$(dirname "${BASH_SOURCE[0]}")/thresholds.sh"
|
|
53
|
+
|
|
54
|
+
if (( fails >= RED_FAIL_MIN )); then
|
|
51
55
|
verdict="RED"; token="remove"; code=1
|
|
52
|
-
elif (( warns >=
|
|
56
|
+
elif (( warns >= YELLOW_WARN_MIN )); then
|
|
53
57
|
verdict="YELLOW"; token="hold"; code=2
|
|
54
58
|
else
|
|
55
59
|
verdict="GREEN"; token="write"; code=0
|