appstore-precheck 1.12.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 +55 -0
- package/README.md +61 -4
- package/bin/cli.js +6 -1
- package/package.json +2 -2
- package/skills/appstore-precheck/SKILL.md +17 -5
- package/skills/appstore-precheck/references/methodology.md +1 -0
- package/skills/appstore-precheck/references/pierre-deep-review.md +2 -2
- package/skills/appstore-precheck/references/simulator-dynamic-review.md +14 -4
- 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,61 @@ 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
|
+
|
|
8
63
|
## [1.12.0] - 2026-07-02
|
|
9
64
|
|
|
10
65
|
Roadmap #5 (final roadmap item): an **optional, opt-in local dynamic simulator tier** —
|
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
|
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.12.
|
|
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.12.
|
|
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
|
|
@@ -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):
|
|
@@ -360,5 +370,7 @@ Follow [`references/simulator-dynamic-review.md`](references/simulator-dynamic-r
|
|
|
360
370
|
## Optional: upload guard hook
|
|
361
371
|
|
|
362
372
|
`hooks/fastlane-guard.sh` blocks `fastlane deliver/pilot/release` unless a fresh `.precheck-pass`
|
|
363
|
-
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
|
|
364
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
|
---
|
|
@@ -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.
|
|
@@ -49,17 +49,27 @@ reporter, or real-device QA.
|
|
|
49
49
|
|
|
50
50
|
## Per-check procedure
|
|
51
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
|
+
|
|
52
58
|
### D1 — Launch without crash
|
|
53
|
-
1. `
|
|
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`).
|
|
54
62
|
2. Observe for a short window; if the app process disappears or a crash alert shows, `DYNAMIC-FINDING`.
|
|
55
|
-
3. `
|
|
63
|
+
3. `mcp__maestro__take_screenshot` at launch as evidence.
|
|
56
64
|
|
|
57
65
|
### D2 — Core screen reachable
|
|
58
|
-
1. After launch, inspect the first real screen (`
|
|
66
|
+
1. After launch, inspect the first real screen (`mcp__maestro__inspect_screen` /
|
|
67
|
+
`mcp__maestro__take_screenshot`).
|
|
59
68
|
2. Flag if stuck on a splash, blank, or an error/"something went wrong" state.
|
|
60
69
|
|
|
61
70
|
### D3 — Paywall renders
|
|
62
|
-
1. If a paywall exists (per the static scan / app structure), navigate to it
|
|
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).
|
|
63
73
|
2. Confirm price + trial/auto-renew/cancel terms are visibly rendered; screenshot. Not applicable if no paywall.
|
|
64
74
|
|
|
65
75
|
### D4 — Permission prompt vs purpose string
|
|
@@ -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
|