appstore-precheck 1.5.1 → 1.6.0
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 +33 -0
- package/README.md +57 -0
- package/package.json +2 -2
- package/skills/appstore-precheck/SKILL.md +6 -1
- package/skills/appstore-precheck/references/methodology.md +1 -1
- package/skills/appstore-precheck/references/pierre-deep-review.md +4 -3
- package/skills/appstore-precheck/scripts/findings.sh +86 -0
- package/skills/appstore-precheck/scripts/scan.sh +169 -41
- package/skills/appstore-precheck/scripts/suppress.sh +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Versioning follows
|
|
4
4
|
[SemVer](https://semver.org/). Released as git tags.
|
|
5
5
|
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [1.6.0] - 2026-07-01
|
|
9
|
+
|
|
10
|
+
Measurement release: structured findings, suppression, and a published
|
|
11
|
+
precision/recall scorecard, plus a measurement-driven false-positive reduction
|
|
12
|
+
round. Default text output stays byte-identical (verified across 11 fixtures);
|
|
13
|
+
every behavior change is gated by TDD and the synthetic-corpus `--check`.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **`scan.sh --format json`**: structured findings envelope (`{tool, version, verdict, summary{fail,warn,pass,suppressed}, findings[{rule_id, severity, guideline, message, file, line, suppressed}]}`) for tooling and measurement. Stable `rule_id` per vector via a 41-slug catalog. Default text output is unchanged.
|
|
17
|
+
- **`.precheck-ignore` suppression**: repo-root rules (`<rule-id>`, `<rule-id> <path-glob>`, `<path-glob>`-exclude) and inline `# precheck:ignore [rule-id]` directives (`//`, `#`, `<!-- -->`). Suppression is emit-time — a suppressed FAIL no longer forces RED — and always transparent: suppressed findings stay in `findings[]` with `suppressed:true`, count in `summary.suppressed`, and a text footer reports the count only when non-zero (byte-identity preserved otherwise).
|
|
18
|
+
- **Precision/recall scorecard**: `scripts/scorecard.sh` (`--check` / `--selftest` / default) over a synthetic labelled corpus (`corpus/synthetic/labels.json`), plus `scripts/scorecard-real.sh` over an 18-app commit-pinned open-source real panel (`corpus/real/`). Published to `docs/scorecard.md` with a mandatory honesty section (neither corpus claims agreement with Apple's actual decisions). Synthetic `--check` is a blocking CI gate; the real panel is non-blocking.
|
|
19
|
+
- File/line threading into locatable checks (surfaced in JSON; text output unchanged).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **False-positive reduction round** (measurement-driven; ~33 FP eliminated across the 18-app panel with zero true-positive loss, char-limit-excluded precision 0.37 → ~0.54):
|
|
23
|
+
- **Analytics detection** now requires an import/API-qualified form, so a bare `Segment` substring (e.g. `UISegmentedControl`) no longer trips 5.1.1.
|
|
24
|
+
- **IAP gate** requires a real purchase API, ignoring `SKStoreReviewController` / `SKAdNetwork` / the `AppStore.` namespace.
|
|
25
|
+
- **Usage-description checks** are capture-gated: playback-only AVFoundation / PhotosPicker no longer false-fire; mic matches `.playAndRecord`; video-only `AVCaptureDevice` no longer triggers the microphone check.
|
|
26
|
+
- **Minimum-functionality navigation** detects UIKit / `NavigationView` / React-Navigation repo-wide.
|
|
27
|
+
- **Screenshots-per-locale**: no in-repo screenshots directory is an advisory PASS (managed in App Store Connect), not a WARN.
|
|
28
|
+
|
|
29
|
+
## [1.5.2] - 2026-06-30
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **§23 (1.6 ATS) false positive**: the App Transport Security check now anchors to the exact `NSAllowsArbitraryLoads` key, so the scoped exceptions `NSAllowsArbitraryLoadsInWebContent` / `NSAllowsArbitraryLoadsForMedia` — which do not disable ATS app-wide and are Apple's recommended alternative — no longer trip a spurious WARN.
|
|
34
|
+
- **Kids-signal double-count (§27 ↔ §39)**: when a child-audience term and a third-party ads/analytics SDK are both present, only §39 (5.1.4) fires now; §27 (2.3.8) is cross-gated so one root signal no longer costs two WARNs against the 5-WARN YELLOW threshold.
|
|
35
|
+
- **§33 (2.5.4) background-mode coverage**: `audio` now recognizes AVKit / `VideoPlayer` / `AVPlayerViewController` / MediaPlayer now-playing APIs, and `fetch` / `processing` recognize `BGTaskScheduler`, reducing "declared but unused" false positives.
|
|
36
|
+
- Pierre deep-review docs: corrected the Tier A definition (22 = all 28 except the 6 Tier B items; previously implied 17), fixed the stale "22 checks" label and broken table-of-contents anchor in the methodology and example files.
|
|
37
|
+
- §28 / §37 evidence now print the plist basename, matching the rest of the batch.
|
|
38
|
+
|
|
6
39
|
## [1.5.1] - 2026-06-30
|
|
7
40
|
|
|
8
41
|
### Changed
|
package/README.md
CHANGED
|
@@ -240,8 +240,14 @@ npx appstore-precheck --fail-on YELLOW
|
|
|
240
240
|
|
|
241
241
|
```bash
|
|
242
242
|
bash skills/appstore-precheck/scripts/scan.sh # from a clone of this repo
|
|
243
|
+
bash skills/appstore-precheck/scripts/scan.sh --format json # structured findings, for tooling
|
|
243
244
|
```
|
|
244
245
|
|
|
246
|
+
`--format json` emits a structured findings envelope (`rule_id`, `severity`, `guideline`,
|
|
247
|
+
`message`, and optional `file`/`line` per finding, plus the verdict summary) instead of the
|
|
248
|
+
default text lines, for scripts and measurement tooling to consume. It's read-only and additive;
|
|
249
|
+
the default text output is unchanged.
|
|
250
|
+
|
|
245
251
|
### CI
|
|
246
252
|
|
|
247
253
|
Drop the static scan into a workflow with the bundled composite action. It fails the
|
|
@@ -320,6 +326,26 @@ and [Phase 2 `fastlane precheck`](examples/fastlane-precheck.md) results.
|
|
|
320
326
|
| 🟡 **YELLOW** | 0 FAIL, 5+ WARN | not written | blocked; needs confirmation |
|
|
321
327
|
| 🔴 **RED** | ≥1 FAIL | removed | blocked; shows the FAIL list |
|
|
322
328
|
|
|
329
|
+
## Measured accuracy
|
|
330
|
+
|
|
331
|
+
[`docs/scorecard.md`](docs/scorecard.md) is generated by `scripts/scorecard.sh` and tracks two
|
|
332
|
+
independent measurements:
|
|
333
|
+
|
|
334
|
+
- **Synthetic precision** — the project's own labelled fixtures (`corpus/synthetic/`), each tagging
|
|
335
|
+
the rule-ids it must fire and the rule-ids that must stay silent. This is a **CI-gated floor**:
|
|
336
|
+
`scripts/scorecard.sh --check` fails the build if precision drops below 0.80 or the doc goes stale.
|
|
337
|
+
- **Real-panel FP rate** — a pinned, license-verified panel of permissively-licensed open-source
|
|
338
|
+
iOS/Swift/React-Native apps (`corpus/real/manifest.json`), scanned and joined against a one-time
|
|
339
|
+
human TP/FP labelling pass (`corpus/real/labels.json`). Every finding is reported `UNLABELED` until
|
|
340
|
+
a human reviews it, so **no unreviewed number is ever published**. Run it yourself with
|
|
341
|
+
`bash scripts/scorecard.sh --real`; CI runs it as a separate **non-blocking, informational** job
|
|
342
|
+
(`continue-on-error: true`) that never gates a PR.
|
|
343
|
+
|
|
344
|
+
**Neither measurement claims agreement with Apple's actual review decisions.** Synthetic precision
|
|
345
|
+
measures intended-behaviour fidelity against fixtures this project wrote; real-panel precision
|
|
346
|
+
measures the false-positive rate on real, unrelated open-source code. See
|
|
347
|
+
[`docs/scorecard.md`](docs/scorecard.md) for the current numbers and full methodology.
|
|
348
|
+
|
|
323
349
|
## Configuration
|
|
324
350
|
|
|
325
351
|
Zero config for a standard fastlane + Xcode layout. The scanner auto-detects your source directory,
|
|
@@ -327,6 +353,37 @@ fastlane metadata, screenshots, String Catalog, paywall view, and locales. Overr
|
|
|
327
353
|
`.appstore-precheck.json` at your repo root (copy
|
|
328
354
|
[`config.example.json`](skills/appstore-precheck/config.example.json)).
|
|
329
355
|
|
|
356
|
+
### Suppressing findings (`.precheck-ignore`)
|
|
357
|
+
|
|
358
|
+
Drop a `.precheck-ignore` file at your repo root to suppress findings you've reviewed and accepted
|
|
359
|
+
on purpose. One entry per line; `#` starts a trailing comment. Three grammar forms:
|
|
360
|
+
|
|
361
|
+
| Form | Example | Effect |
|
|
362
|
+
|------|---------|--------|
|
|
363
|
+
| `<rule-id>` | `account-no-delete` | suppress that rule everywhere in the repo |
|
|
364
|
+
| `<rule-id> <path-glob>` | `ats-arbitrary-loads ios/Legacy/` | suppress that rule only under the matching path |
|
|
365
|
+
| `<path-glob>` | `vendor/` | exclude the matching path from scanning entirely |
|
|
366
|
+
|
|
367
|
+
You can also suppress a single finding inline, with a `//`, `#`, or `<!-- -->` comment marker on the
|
|
368
|
+
flagged line or the line directly above it:
|
|
369
|
+
|
|
370
|
+
```swift
|
|
371
|
+
let x = UIWebView() // precheck:ignore private-api
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
```html
|
|
375
|
+
<!-- precheck:ignore -->
|
|
376
|
+
<key>NSAllowsArbitraryLoads</key>
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
A bare `precheck:ignore` (no rule-id) suppresses **any** rule on that line; a scoped
|
|
380
|
+
`precheck:ignore <rule-id>` only suppresses that one rule, leaving every other rule on the same line
|
|
381
|
+
free to fire.
|
|
382
|
+
|
|
383
|
+
**Suppressed findings are always counted, never silently hidden.** `scan.sh --format json` still
|
|
384
|
+
emits them with `"suppressed": true`, and the text/summary output always states how many findings
|
|
385
|
+
were suppressed.
|
|
386
|
+
|
|
330
387
|
## Cross-tool support
|
|
331
388
|
|
|
332
389
|
`SKILL.md` follows the [Agent Skills open standard](https://agentskills.io), with no per-tool conversion.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appstore-precheck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
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 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"
|
|
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"
|
|
29
29
|
},
|
|
30
30
|
"keywords": ["ios", "app-store", "fastlane", "agent-skill", "claude-code", "precheck"]
|
|
31
31
|
}
|
|
@@ -4,7 +4,7 @@ description: Read-only pre-submission check for an iOS app before App Store revi
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: Berkay Turk
|
|
7
|
-
version: 1.
|
|
7
|
+
version: 1.6.0
|
|
8
8
|
allowed-tools: Bash Read Grep Glob WebFetch
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -136,6 +136,11 @@ full check table is in
|
|
|
136
136
|
The scanner is portable Bash, so you can also run it directly, outside any agent, for a quick CI
|
|
137
137
|
or pre-commit check.
|
|
138
138
|
|
|
139
|
+
`scan.sh --format json` emits a structured findings envelope (`rule_id`, `severity`, `guideline`,
|
|
140
|
+
`message`, optional `file`/`line` per finding, plus the verdict summary) instead of the default
|
|
141
|
+
text lines, for tooling and measurement to consume. It's read-only and additive; the default text
|
|
142
|
+
output is unchanged.
|
|
143
|
+
|
|
139
144
|
### Phase 2: Apple's official `fastlane precheck`
|
|
140
145
|
|
|
141
146
|
Requires `bundleId` in config (or pass `app_identifier` directly) and App Store Connect API
|
|
@@ -8,7 +8,7 @@ whole file to run the skill.
|
|
|
8
8
|
- [Phase 0: Guideline drift check](#phase-0-guideline-drift-check)
|
|
9
9
|
- [Phase 1: Rejection vectors](#phase-1-rejection-vectors)
|
|
10
10
|
- [Phase 3: Pierre explains every finding](#phase-3-pierre-explains-every-finding)
|
|
11
|
-
- [Phase 4: Pierre deep review (
|
|
11
|
+
- [Phase 4: Pierre deep review (28 checks)](#phase-4-pierre-deep-review-28-semantic-checks)
|
|
12
12
|
- [Auto-detection rules](#auto-detection-rules)
|
|
13
13
|
- [Verdict thresholds](#verdict-thresholds)
|
|
14
14
|
- [Pre-submit manual checklist](#pre-submit-manual-checklist)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Phase 4: Pierre deep review (28 semantic checks)
|
|
2
2
|
|
|
3
3
|
After Phase 3 (explaining every scan FAIL/WARN), Pierre runs a **read-only, project-wide
|
|
4
|
-
semantic review** of 28 guideline areas the static scanner cannot fully judge.
|
|
5
|
-
(
|
|
6
|
-
|
|
4
|
+
semantic review** of 28 guideline areas the static scanner cannot fully judge. The **22 Tier A**
|
|
5
|
+
checks (all 28 except the Tier B items below) are high-confidence; the **6 Tier B v1** checks
|
|
6
|
+
**4, 5, 7, 10, 15, and 28** are heuristic advisory (higher false-positive risk, still useful
|
|
7
|
+
pre-submit signals).
|
|
7
8
|
|
|
8
9
|
This is the **Review Simulator** layer: Pierre reads Swift, metadata, entitlements, screenshots,
|
|
9
10
|
xcstrings, paywall views, review notes, and fetches live privacy/support URLs — then cross-checks
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# findings.sh — structured-findings layer for scan.sh.
|
|
3
|
+
# Sourced by scan.sh. Adds a parallel machine-readable channel; text output is untouched.
|
|
4
|
+
# Bash 3.2 compatible: NO associative arrays (catalog is a case lookup).
|
|
5
|
+
|
|
6
|
+
# rule_slug <section-number> -> stable kebab-case slug, or "" if unknown.
|
|
7
|
+
rule_slug() {
|
|
8
|
+
case "$1" in
|
|
9
|
+
1) echo privacy-manifest-parity ;; 2) echo usage-description-crosscheck ;;
|
|
10
|
+
3) echo att-usage ;; 4) echo competitor-mentions ;;
|
|
11
|
+
5) echo metadata-char-limits ;; 6) echo locale-metadata-parity ;;
|
|
12
|
+
7) echo screenshots-per-locale ;; 8) echo trial-disclosure ;;
|
|
13
|
+
9) echo autorenew-disclosure ;; 10) echo subscription-links-restore ;;
|
|
14
|
+
11) echo private-api ;; 12) echo min-functionality-nav ;;
|
|
15
|
+
13) echo screentime-justification ;; 14) echo siwa-parity ;;
|
|
16
|
+
15) echo external-purchase-link ;; 16) echo tracking-sdk-no-att ;;
|
|
17
|
+
17) echo export-compliance ;; 18) echo support-privacy-url ;;
|
|
18
|
+
19) echo analytics-privacyinfo-mismatch ;; 20) echo placeholder-metadata ;;
|
|
19
|
+
21) echo thirdparty-payment-sdk ;; 22) echo ugc-no-moderation ;;
|
|
20
|
+
23) echo ats-arbitrary-loads ;; 24) echo applepay-recurring-disclosure ;;
|
|
21
|
+
25) echo custom-review-prompt ;; 26) echo misleading-marketing ;;
|
|
22
|
+
27) echo kids-wording ;; 28) echo keyboard-full-access ;;
|
|
23
|
+
29) echo health-icloud-sync ;; 30) echo vpn-networkextension ;;
|
|
24
|
+
31) echo demo-account ;; 32) echo executable-code-download ;;
|
|
25
|
+
33) echo background-modes-unused ;; 34) echo crypto-wallet-mining ;;
|
|
26
|
+
35) echo webview-wrapper ;; 36) echo remote-desktop ;;
|
|
27
|
+
37) echo safari-extension ;; 38) echo account-no-delete ;;
|
|
28
|
+
39) echo kids-ads-analytics ;; 40) echo realmoney-gambling ;;
|
|
29
|
+
41) echo mdm ;; *) echo "" ;;
|
|
30
|
+
esac
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_CURRENT_RULE=""
|
|
34
|
+
set_rule() { _CURRENT_RULE="$1"; }
|
|
35
|
+
|
|
36
|
+
: "${FINDINGS_TMP:=}"
|
|
37
|
+
|
|
38
|
+
# _record <severity> <message> [<file>] [<line>]
|
|
39
|
+
_record() {
|
|
40
|
+
[[ -z "$FINDINGS_TMP" ]] && return 0
|
|
41
|
+
local sev="$1" msg="$2" file="${3:-}" line="${4:-}" guideline
|
|
42
|
+
guideline="$(printf '%s' "$msg" | awk '{print $1}')"
|
|
43
|
+
jq -nc --arg r "$_CURRENT_RULE" --arg s "$sev" --arg g "$guideline" \
|
|
44
|
+
--arg m "$msg" --arg f "$file" --arg l "$line" \
|
|
45
|
+
'{rule_id:$r, severity:$s, guideline:$g, message:$m,
|
|
46
|
+
file:(if $f=="" then null else $f end),
|
|
47
|
+
line:(if $l=="" then null else ($l|tonumber) end),
|
|
48
|
+
suppressed:false}' >> "$FINDINGS_TMP"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
: "${_SUPPRESSED_COUNT:=0}"
|
|
52
|
+
|
|
53
|
+
# _record_suppressed <severity> <message> [<file>] [<line>]
|
|
54
|
+
# Same JSONL record as _record but suppressed:true, and bumps the counter.
|
|
55
|
+
_record_suppressed() {
|
|
56
|
+
[[ -z "$FINDINGS_TMP" ]] && { _SUPPRESSED_COUNT=$((_SUPPRESSED_COUNT + 1)); return 0; }
|
|
57
|
+
local sev="$1" msg="$2" file="${3:-}" line="${4:-}" guideline
|
|
58
|
+
guideline="$(printf '%s' "$msg" | awk '{print $1}')"
|
|
59
|
+
jq -nc --arg r "$_CURRENT_RULE" --arg s "$sev" --arg g "$guideline" \
|
|
60
|
+
--arg m "$msg" --arg f "$file" --arg l "$line" \
|
|
61
|
+
'{rule_id:$r, severity:$s, guideline:$g, message:$m,
|
|
62
|
+
file:(if $f=="" then null else $f end),
|
|
63
|
+
line:(if $l=="" then null else ($l|tonumber) end),
|
|
64
|
+
suppressed:true}' >> "$FINDINGS_TMP"
|
|
65
|
+
_SUPPRESSED_COUNT=$((_SUPPRESSED_COUNT + 1))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
: "${PRECHECK_VERSION:=dev}"
|
|
69
|
+
|
|
70
|
+
# render_json -> prints the structured envelope. Verdict reuses verdict.sh thresholds
|
|
71
|
+
# (RED >=1 FAIL; YELLOW >=5 WARN; else GREEN), counting non-suppressed findings only.
|
|
72
|
+
render_json() {
|
|
73
|
+
local buf="${FINDINGS_TMP:-/dev/null}"
|
|
74
|
+
[[ -s "$buf" ]] || { printf '%s\n' '{"findings":[]}' | jq \
|
|
75
|
+
--arg v "$PRECHECK_VERSION" '{tool:"appstore-precheck",version:$v,verdict:"GREEN",summary:{fail:0,warn:0,pass:0,suppressed:0},findings:[]}'; return 0; }
|
|
76
|
+
jq -s --arg v "$PRECHECK_VERSION" '
|
|
77
|
+
(map(select(.suppressed==false))) as $live
|
|
78
|
+
| ($live|map(select(.severity=="FAIL"))|length) as $f
|
|
79
|
+
| ($live|map(select(.severity=="WARN"))|length) as $w
|
|
80
|
+
| ($live|map(select(.severity=="PASS"))|length) as $p
|
|
81
|
+
| (map(select(.suppressed==true))|length) as $s
|
|
82
|
+
| (if $f>=1 then "RED" elif $w>=5 then "YELLOW" else "GREEN" end) as $verdict
|
|
83
|
+
| {tool:"appstore-precheck", version:$v, verdict:$verdict,
|
|
84
|
+
summary:{fail:$f, warn:$w, pass:$p, suppressed:$s},
|
|
85
|
+
findings: .}' "$buf"
|
|
86
|
+
}
|
|
@@ -12,6 +12,28 @@ set -u
|
|
|
12
12
|
ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || ROOT="$(pwd)"
|
|
13
13
|
cd "$ROOT" || { echo "FAIL: repo-root — could not enter repository root"; exit 0; }
|
|
14
14
|
|
|
15
|
+
source "$(dirname "${BASH_SOURCE[0]}")/findings.sh"
|
|
16
|
+
source "$(dirname "${BASH_SOURCE[0]}")/suppress.sh"
|
|
17
|
+
FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
|
|
18
|
+
trap 'rm -f "$FINDINGS_TMP"' EXIT
|
|
19
|
+
FORMAT="text"
|
|
20
|
+
# The envelope `version` is the appstore-precheck TOOL's own version (from this
|
|
21
|
+
# skill's SKILL.md), never the scanned repo's — $ROOT above is the SCANNED app's
|
|
22
|
+
# git root, so reading its package.json here would leak the wrong version.
|
|
23
|
+
PRECHECK_VERSION="$(grep -m1 -E '^[[:space:]]*version:' "$(dirname "${BASH_SOURCE[0]}")/../SKILL.md" 2>/dev/null | sed -E 's/.*version:[[:space:]]*//; s/[[:space:]]*$//')"
|
|
24
|
+
[[ -z "$PRECHECK_VERSION" ]] && PRECHECK_VERSION="dev"
|
|
25
|
+
|
|
26
|
+
while [[ $# -gt 0 ]]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--format)
|
|
29
|
+
if [[ $# -lt 2 ]]; then echo "scan.sh: --format needs a value (text|json)" >&2; exit 64; fi
|
|
30
|
+
FORMAT="$2"; shift 2 ;;
|
|
31
|
+
--format=*) FORMAT="${1#*=}"; shift ;;
|
|
32
|
+
*) shift ;;
|
|
33
|
+
esac
|
|
34
|
+
done
|
|
35
|
+
[[ "$FORMAT" == json || "$FORMAT" == text ]] || { echo "scan.sh: --format must be text|json" >&2; exit 64; }
|
|
36
|
+
|
|
15
37
|
CONFIG="${APPSTORE_PRECHECK_CONFIG:-.appstore-precheck.json}"
|
|
16
38
|
have_jq() { command -v jq >/dev/null 2>&1; }
|
|
17
39
|
|
|
@@ -32,9 +54,13 @@ cfg_bool() { # cfg_bool <json-path> — echoes "true"/"false"
|
|
|
32
54
|
echo "false"
|
|
33
55
|
}
|
|
34
56
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
57
|
+
_LAST_SUPPRESSED=0
|
|
58
|
+
fail() { if is_suppressed "$_CURRENT_RULE" "${2:-}" "${3:-}"; then _record_suppressed FAIL "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=1; else echo "FAIL: $1"; _record FAIL "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=0; fi; }
|
|
59
|
+
warn() { if is_suppressed "$_CURRENT_RULE" "${2:-}" "${3:-}"; then _record_suppressed WARN "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=1; else echo "WARN: $1"; _record WARN "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=0; fi; }
|
|
60
|
+
pass() { if is_suppressed "$_CURRENT_RULE" "${2:-}" "${3:-}"; then _record_suppressed PASS "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=1; else echo "PASS: $1"; _record PASS "$1" "${2:-}" "${3:-}"; _LAST_SUPPRESSED=0; fi; }
|
|
61
|
+
|
|
62
|
+
# detail <text> — indented evidence under the previous finding; skipped when it was suppressed.
|
|
63
|
+
detail() { [[ "${_LAST_SUPPRESSED:-0}" == 1 ]] || printf '%s\n' "$1" | sed 's/^/ /'; }
|
|
38
64
|
|
|
39
65
|
# ===================================================================
|
|
40
66
|
# Auto-detection of the project layout
|
|
@@ -55,6 +81,17 @@ GREP_PRUNE=( --exclude-dir=.git --exclude-dir=Pods --exclude-dir=Carthage
|
|
|
55
81
|
--exclude-dir=.claude --exclude-dir=worktrees
|
|
56
82
|
--exclude-dir=node_modules --exclude-dir=vendor )
|
|
57
83
|
|
|
84
|
+
# Load .precheck-ignore path exclusions (Task 2 suppress.sh) and extend both
|
|
85
|
+
# prune sets so a suppressed path is skipped by detection AND grep passes.
|
|
86
|
+
load_precheck_ignore "$ROOT"
|
|
87
|
+
while IFS= read -r _g; do
|
|
88
|
+
[[ -z "$_g" ]] && continue
|
|
89
|
+
PRUNE+=( -not -path "*/$_g/*" -not -path "$_g/*" )
|
|
90
|
+
GREP_PRUNE+=( --exclude-dir="${_g##*/}" )
|
|
91
|
+
done <<PRUNE_GLOBS
|
|
92
|
+
$(precheck_prune_globs)
|
|
93
|
+
PRUNE_GLOBS
|
|
94
|
+
|
|
58
95
|
# Reads newline-separated paths on stdin, prints the one with the fewest path
|
|
59
96
|
# segments (the shallowest, i.e. the real project copy rather than a nested one).
|
|
60
97
|
pick_shallowest() { awk '{ n=gsub(/\//,"/"); print n"\t"$0 }' | sort -n | head -1 | cut -f2-; }
|
|
@@ -150,6 +187,8 @@ SUB_KEY="$(cfg '.disclosureKeys.subscription' 'subscription_disclosure')"
|
|
|
150
187
|
TRIAL_KEY="$(cfg '.disclosureKeys.trial' 'subscription_trial_disclosure')"
|
|
151
188
|
CHECK_FAMILY="$(cfg_bool '.optionalChecks.familyControls')"
|
|
152
189
|
|
|
190
|
+
if [[ "$FORMAT" == json ]]; then exec 4>&1 1>/dev/null; fi
|
|
191
|
+
|
|
153
192
|
echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='${XCSTRINGS:-?}' locales=${#LOCALES[@]}"
|
|
154
193
|
|
|
155
194
|
# ===================================================================
|
|
@@ -159,14 +198,15 @@ echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='
|
|
|
159
198
|
# (v) "Account Sign-In" is a different rule — its account-deletion requirement is
|
|
160
199
|
# checked separately in §38.
|
|
161
200
|
# ===================================================================
|
|
201
|
+
set_rule "privacy-manifest-parity"
|
|
162
202
|
check_required_reason_api() {
|
|
163
203
|
local cat="$1" pattern="$2" hits declared
|
|
164
204
|
hits=$(grep -rEl "$pattern" "$IOS_DIR" --include="*.swift" 2>/dev/null | head -3)
|
|
165
205
|
declared=$(grep -c "NSPrivacyAccessedAPICategory${cat}" "$PRIVACY_FILE" 2>/dev/null)
|
|
166
206
|
if [[ -n "$hits" && "${declared:-0}" -eq 0 ]]; then
|
|
167
|
-
fail "5.1.1 Required Reason API — '$cat' used in code (e.g. $(echo "$hits" | head -1)) but not declared in PrivacyInfo.xcprivacy"
|
|
207
|
+
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"
|
|
168
208
|
elif [[ -z "$hits" && "${declared:-0}" -gt 0 ]]; then
|
|
169
|
-
warn "5.1.1 PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)"
|
|
209
|
+
warn "5.1.1 PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)" "$PRIVACY_FILE"
|
|
170
210
|
elif [[ -n "$hits" && "${declared:-0}" -gt 0 ]]; then
|
|
171
211
|
pass "5.1.1 Required Reason API — '$cat' parity OK"
|
|
172
212
|
fi
|
|
@@ -174,7 +214,7 @@ check_required_reason_api() {
|
|
|
174
214
|
if [[ -z "$IOS_DIR" ]]; then
|
|
175
215
|
warn "layout — could not auto-detect iOS source dir; set .iosSourceDir in $CONFIG"
|
|
176
216
|
elif [[ -z "$PRIVACY_FILE" ]]; then
|
|
177
|
-
fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)"
|
|
217
|
+
fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)" "$INFO_PLIST"
|
|
178
218
|
else
|
|
179
219
|
check_required_reason_api "UserDefaults" 'UserDefaults|@AppStorage'
|
|
180
220
|
check_required_reason_api "FileTimestamp" 'attributesOfItem|creationDate|modificationDate|\.fileCreationDate|\.fileModificationDate'
|
|
@@ -186,26 +226,52 @@ fi
|
|
|
186
226
|
# ===================================================================
|
|
187
227
|
# §2 — 5.1.1 NSUsageDescription cross-check (Info.plist)
|
|
188
228
|
# ===================================================================
|
|
229
|
+
set_rule "usage-description-crosscheck"
|
|
189
230
|
if [[ ! -f "$INFO_PLIST" ]]; then
|
|
190
|
-
[[ -n "$IOS_DIR" ]] && warn "5.1.1 Info.plist not found at $INFO_PLIST (modern Xcode may auto-generate it; verify purpose strings in build settings)"
|
|
231
|
+
[[ -n "$IOS_DIR" ]] && warn "5.1.1 Info.plist not found at $INFO_PLIST (modern Xcode may auto-generate it; verify purpose strings in build settings)" "$INFO_PLIST"
|
|
191
232
|
else
|
|
192
233
|
awk '/NS[A-Za-z]+UsageDescription/{key=$0; getline; if($0 ~ /<string>[[:space:]]*<\/string>/) print "EMPTY:"key}' "$INFO_PLIST" | while read -r line; do
|
|
193
|
-
[[ -n "$line" ]] && fail "5.1.1 Purpose String — $line (empty usage description is rejected by App Review)"
|
|
234
|
+
[[ -n "$line" ]] && fail "5.1.1 Purpose String — $line (empty usage description is rejected by App Review)" "$INFO_PLIST"
|
|
194
235
|
done
|
|
195
236
|
for fw in \
|
|
196
237
|
"FamilyControls|ManagedSettings|DeviceActivity:NSFamilyControlsUsageDescription" \
|
|
197
238
|
"CoreLocation:NSLocationWhenInUseUsageDescription" \
|
|
198
|
-
"AVFoundation:NSCameraUsageDescription|NSMicrophoneUsageDescription" \
|
|
199
|
-
"Photos:NSPhotoLibraryUsageDescription" \
|
|
200
239
|
"Contacts:NSContactsUsageDescription" \
|
|
201
240
|
"HealthKit:NSHealthShareUsageDescription"; do
|
|
202
241
|
framework="${fw%%:*}"; needed_key="${fw##*:}"
|
|
203
242
|
if grep -rqE "import ($framework)|($framework)\." "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
204
243
|
if ! grep -qE "$needed_key" "$INFO_PLIST" 2>/dev/null; then
|
|
205
|
-
fail "5.1.1 framework '${framework%%|*}' imported but Info.plist is missing '${needed_key%%|*}'"
|
|
244
|
+
fail "5.1.1 framework '${framework%%|*}' imported but Info.plist is missing '${needed_key%%|*}'" "$INFO_PLIST"
|
|
206
245
|
fi
|
|
207
246
|
fi
|
|
208
247
|
done
|
|
248
|
+
# AVFoundation and Photos are gated on real capture/read APIs, not bare
|
|
249
|
+
# imports: an import only signals framework linkage, not camera/mic/library
|
|
250
|
+
# access (e.g. AVFoundation is commonly imported for playback-only use via
|
|
251
|
+
# AVAudioPlayer/AVPlayer, and Photos/PhotosUI's PhotosPicker/PHPickerViewController
|
|
252
|
+
# run out-of-process and need no Info.plist key at all).
|
|
253
|
+
# Camera: purpose string required only when a capture API is actually used.
|
|
254
|
+
if grep -rqE 'AVCaptureDevice|AVCaptureSession|UIImagePickerController' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
255
|
+
grep -qE 'NSCameraUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
256
|
+
fail "5.1.1 camera capture API used but Info.plist is missing 'NSCameraUsageDescription'" "$INFO_PLIST"
|
|
257
|
+
fi
|
|
258
|
+
# Microphone: required only for recording/capture, not playback. Matched
|
|
259
|
+
# separately from the camera check above: a bare AVCaptureDevice only
|
|
260
|
+
# implies microphone use when it is audio-typed (`.audio` device/media
|
|
261
|
+
# type), not for a video-only capture device.
|
|
262
|
+
if grep -rqE 'AVAudioRecorder|installTap\(|AVAudioEngine\(|AVAudioSession[^;]*\.(playAndRecord|record)|AVCaptureDevice[^;)]*\.audio|for:[[:space:]]*\.audio' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
263
|
+
grep -qE 'NSMicrophoneUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
264
|
+
fail "5.1.1 microphone/recording API used but Info.plist is missing 'NSMicrophoneUsageDescription'" "$INFO_PLIST"
|
|
265
|
+
fi
|
|
266
|
+
# Photo library READ: PhotosPicker/PHPicker need no key; only true read/fetch APIs do.
|
|
267
|
+
if grep -rqE 'PHAsset\b|PHFetchResult|PHImageManager|fetchAssets|PHAssetCollection' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
268
|
+
grep -qE 'NSPhotoLibraryUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
269
|
+
fail "5.1.1 Photos read API used but Info.plist is missing 'NSPhotoLibraryUsageDescription'" "$INFO_PLIST"
|
|
270
|
+
elif grep -rqE 'PHAssetCreationRequest|UIImageWriteToSavedPhotosAlbum|performChanges' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
271
|
+
# Add-only save: covered by the add-only key.
|
|
272
|
+
grep -qE 'NSPhotoLibraryAddUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
273
|
+
fail "5.1.1 Photos add-only API used but Info.plist is missing 'NSPhotoLibraryAddUsageDescription'" "$INFO_PLIST"
|
|
274
|
+
fi
|
|
209
275
|
fi
|
|
210
276
|
|
|
211
277
|
# ===================================================================
|
|
@@ -214,13 +280,14 @@ fi
|
|
|
214
280
|
# Ad / attribution / IDFA SDK signal, shared with §16. The reverse of §3: §3 fires
|
|
215
281
|
# when the ATT framework IS imported; §16 fires when a tracking SDK is present but
|
|
216
282
|
# the ATT prompt is NOT. Computed once here so the two checks never contradict.
|
|
283
|
+
set_rule "att-usage"
|
|
217
284
|
tracking_sdk=$(grep -rlE 'advertisingIdentifier|ASIdentifierManager|GADMobileAds|GoogleMobileAds|AppLovinSDK|ALSdk|AppsFlyerLib|import Adjust|Adjust\.|FBAudienceNetwork|BranchSDK|IronSource' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
218
285
|
att_used=$(grep -rlE 'AppTrackingTransparency|ATTrackingManager' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
219
286
|
if [[ -n "$att_used" ]]; then
|
|
220
287
|
if grep -q "NSUserTrackingUsageDescription" "$INFO_PLIST" 2>/dev/null; then
|
|
221
288
|
pass "5.1.2 ATT — used + NSUserTrackingUsageDescription present"
|
|
222
289
|
else
|
|
223
|
-
fail "5.1.2 ATT framework imported but NSUserTrackingUsageDescription missing in Info.plist ($att_used)"
|
|
290
|
+
fail "5.1.2 ATT framework imported but NSUserTrackingUsageDescription missing in Info.plist ($att_used)" "$INFO_PLIST"
|
|
224
291
|
fi
|
|
225
292
|
elif [[ -z "$tracking_sdk" ]]; then
|
|
226
293
|
pass "5.1.2 ATT — not used (no tracking)"
|
|
@@ -231,12 +298,13 @@ fi
|
|
|
231
298
|
# ===================================================================
|
|
232
299
|
# §4 — 2.3.10 Other-platform / competitor mentions in metadata
|
|
233
300
|
# ===================================================================
|
|
301
|
+
set_rule "competitor-mentions"
|
|
234
302
|
if [[ -d "$META_DIR" ]]; then
|
|
235
303
|
banned_re='Android|Google[[:space:]]?Play|Play[[:space:]]?Store|Windows[[:space:]]?Phone|Samsung Galaxy|Huawei AppGallery|F-Droid'
|
|
236
304
|
hits=$(grep -rEnI "$banned_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -30)
|
|
237
305
|
if [[ -n "$hits" ]]; then
|
|
238
306
|
fail "2.3.10 Other-platform mention — banned reference in metadata:"
|
|
239
|
-
|
|
307
|
+
detail "$hits"
|
|
240
308
|
else
|
|
241
309
|
pass "2.3.10 Other-platform mentions — metadata clean"
|
|
242
310
|
fi
|
|
@@ -245,6 +313,7 @@ fi
|
|
|
245
313
|
# ===================================================================
|
|
246
314
|
# §5 — 2.3.1 Metadata character limits (Unicode codepoints, like ASC)
|
|
247
315
|
# ===================================================================
|
|
316
|
+
set_rule "metadata-char-limits"
|
|
248
317
|
check_len() {
|
|
249
318
|
local file="$1" limit="$2" label="$3" len
|
|
250
319
|
[[ -f "$file" ]] || return
|
|
@@ -254,7 +323,7 @@ check_len() {
|
|
|
254
323
|
len=$(wc -m < "$file" | tr -d ' ')
|
|
255
324
|
fi
|
|
256
325
|
[[ -z "$len" ]] && return
|
|
257
|
-
(( len > limit )) && fail "2.3.1 $label — $file ${len} chars (limit ${limit})"
|
|
326
|
+
(( len > limit )) && fail "2.3.1 $label — $file ${len} chars (limit ${limit})" "$file"
|
|
258
327
|
}
|
|
259
328
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
260
329
|
d="$META_DIR/$loc"; [[ -d "$d" ]] || continue
|
|
@@ -268,6 +337,7 @@ done
|
|
|
268
337
|
# ===================================================================
|
|
269
338
|
# §6 — 2.3.7 Localized metadata parity across all detected locales
|
|
270
339
|
# ===================================================================
|
|
340
|
+
set_rule "locale-metadata-parity"
|
|
271
341
|
if (( ${#LOCALES[@]} > 0 )); then
|
|
272
342
|
expected_files=(name.txt subtitle.txt description.txt keywords.txt)
|
|
273
343
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
@@ -294,6 +364,7 @@ fi
|
|
|
294
364
|
# ===================================================================
|
|
295
365
|
# §7 — 2.3.3 Screenshots per locale
|
|
296
366
|
# ===================================================================
|
|
367
|
+
set_rule "screenshots-per-locale"
|
|
297
368
|
if [[ -n "$SCREEN_DIR" && -d "$SCREEN_DIR" ]]; then
|
|
298
369
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
299
370
|
d="$SCREEN_DIR/$loc"
|
|
@@ -307,20 +378,24 @@ if [[ -n "$SCREEN_DIR" && -d "$SCREEN_DIR" ]]; then
|
|
|
307
378
|
done
|
|
308
379
|
pass "2.3.3 Screenshots — checked ${#LOCALES[@]} locales under $SCREEN_DIR"
|
|
309
380
|
else
|
|
310
|
-
|
|
381
|
+
pass "2.3.3 Screenshots — no in-repo screenshots dir; assumed managed in App Store Connect (set .screenshotsDir to check in-repo)"
|
|
311
382
|
fi
|
|
312
383
|
|
|
313
384
|
# ===================================================================
|
|
314
385
|
# In-app purchase gate — only run 3.1.2 checks if IAP signals exist
|
|
315
386
|
# ===================================================================
|
|
316
387
|
iap_detected=""
|
|
317
|
-
grep -rqE '
|
|
388
|
+
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" --include="*.swift" 2>/dev/null && iap_detected=1
|
|
318
389
|
[[ -n "$SUB_VIEW" ]] && iap_detected=1
|
|
319
390
|
|
|
320
391
|
if [[ -z "$iap_detected" ]]; then
|
|
392
|
+
# Not one of the 41 catalog sections — clear the rule_id so this PASS doesn't
|
|
393
|
+
# inherit §7's "screenshots-per-locale" slug in the JSON output.
|
|
394
|
+
set_rule ""
|
|
321
395
|
pass "3.1.2 IAP — no in-app purchase / subscription signals detected, skipping paywall checks"
|
|
322
396
|
else
|
|
323
397
|
# ---- §8 3.1.2 Trial disclosure -------------------------------------------------
|
|
398
|
+
set_rule "trial-disclosure"
|
|
324
399
|
trial_re='free[[:space:]]?trial|trial[[:space:]]?period|ücretsiz[[:space:]]?dene|kostenlos[[:space:]]?test|essai[[:space:]]?gratuit|prueba[[:space:]]?grat|無料.*トライアル|무료[[:space:]]?체험'
|
|
325
400
|
if [[ -n "$XCSTRINGS" ]] && grep -EqI "$trial_re" "$XCSTRINGS" 2>/dev/null; then
|
|
326
401
|
if grep -q "$TRIAL_KEY" "$XCSTRINGS" 2>/dev/null; then
|
|
@@ -333,6 +408,7 @@ else
|
|
|
333
408
|
fi
|
|
334
409
|
|
|
335
410
|
# ---- §9 3.1.2 Auto-renew subscription disclosure -------------------------------
|
|
411
|
+
set_rule "autorenew-disclosure"
|
|
336
412
|
if [[ -n "$XCSTRINGS" ]]; then
|
|
337
413
|
if have_jq && jq -e ".strings | has(\"$SUB_KEY\")" "$XCSTRINGS" >/dev/null 2>&1; then
|
|
338
414
|
pass "3.1.2 subscription disclosure key '$SUB_KEY' present"
|
|
@@ -353,6 +429,7 @@ else
|
|
|
353
429
|
# Grep across the whole paywall cluster: a link present in ANY paywall view satisfies
|
|
354
430
|
# the requirement; only its absence from ALL of them is a FAIL. SUB_VIEW names the
|
|
355
431
|
# representative file in messages.
|
|
432
|
+
set_rule "subscription-links-restore"
|
|
356
433
|
if (( ${#PAYWALL_FILES[@]} > 0 )); then
|
|
357
434
|
if grep -qE 'restore|restorePurchases' "${PAYWALL_FILES[@]}"; then
|
|
358
435
|
pass "3.1.2 Restore Purchases — present in $(basename "$SUB_VIEW")"
|
|
@@ -377,11 +454,15 @@ fi
|
|
|
377
454
|
# ===================================================================
|
|
378
455
|
# §11 — 2.5.1 Private / banned API
|
|
379
456
|
# ===================================================================
|
|
457
|
+
set_rule "private-api"
|
|
380
458
|
banned_api='UIWebView|setSelectionIndicatorImage|_UIBackdropView|NSURLConnection[^a-zA-Z]|UIAlertView[^Q]|UIActionSheet[^Q]'
|
|
381
459
|
banned_hits=$(grep -rEnI "$banned_api" "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -5)
|
|
382
460
|
if [[ -n "$banned_hits" ]]; then
|
|
383
|
-
|
|
384
|
-
|
|
461
|
+
pa_first="$(printf '%s\n' "$banned_hits" | head -1)" # "path:line:match"
|
|
462
|
+
pa_file="${pa_first%%:*}"
|
|
463
|
+
pa_rest="${pa_first#*:}"; pa_line="${pa_rest%%:*}"
|
|
464
|
+
fail "2.5.1 Private/Deprecated API:" "$pa_file" "$pa_line"
|
|
465
|
+
detail "$banned_hits"
|
|
385
466
|
else
|
|
386
467
|
pass "2.5.1 Private API — clean"
|
|
387
468
|
fi
|
|
@@ -389,7 +470,8 @@ fi
|
|
|
389
470
|
# ===================================================================
|
|
390
471
|
# §12 — 4.2 Minimum functionality — navigation hubs
|
|
391
472
|
# ===================================================================
|
|
392
|
-
|
|
473
|
+
set_rule "min-functionality-nav"
|
|
474
|
+
tab_count=$(grep -rcE 'TabView|NavigationStack|NavigationSplitView|NavigationView|UITabBarController|UINavigationController|createBottomTabNavigator|createStackNavigator|createNativeStackNavigator' . "${GREP_PRUNE[@]}" --include='*.swift' --include='*.m' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
|
|
393
475
|
if (( tab_count < 1 )); then
|
|
394
476
|
warn "4.2 Minimum functionality — no TabView/NavigationStack found (heuristic, may be a false positive)"
|
|
395
477
|
else
|
|
@@ -399,6 +481,7 @@ fi
|
|
|
399
481
|
# ===================================================================
|
|
400
482
|
# §13 — 5.1.5 Screen Time / sensitive-API justification (optional, opt-in)
|
|
401
483
|
# ===================================================================
|
|
484
|
+
set_rule "screentime-justification"
|
|
402
485
|
if [[ "$CHECK_FAMILY" == "true" ]] && grep -q "NSFamilyControlsUsageDescription" "$INFO_PLIST" 2>/dev/null; then
|
|
403
486
|
if [[ -n "$REVIEW_PREP" && -f "$REVIEW_PREP" ]] && grep -qiE 'family|screen[[:space:]]?time' "$REVIEW_PREP" 2>/dev/null; then
|
|
404
487
|
pass "5.1.5 Screen Time API — reviewer-prep justification note present"
|
|
@@ -410,6 +493,7 @@ fi
|
|
|
410
493
|
# ===================================================================
|
|
411
494
|
# §14 — 4.8 Sign in with Apple parity (only when a third-party social login is used)
|
|
412
495
|
# ===================================================================
|
|
496
|
+
set_rule "siwa-parity"
|
|
413
497
|
if [[ -n "$IOS_DIR" ]]; then
|
|
414
498
|
social_login=$(grep -rlE 'GIDSignIn|import GoogleSignIn|FBSDKLoginKit|FBSDKLoginManager|FacebookLogin|import Auth0|LineSDK|VKSdkAuthorization' "$IOS_DIR" --include='*.swift' 2>/dev/null | head -1)
|
|
415
499
|
if [[ -n "$social_login" ]]; then
|
|
@@ -424,6 +508,7 @@ fi
|
|
|
424
508
|
# ===================================================================
|
|
425
509
|
# §15 — 3.1.1(a) External purchase link (StoreKit External Purchase)
|
|
426
510
|
# ===================================================================
|
|
511
|
+
set_rule "external-purchase-link"
|
|
427
512
|
ext_purchase=""
|
|
428
513
|
grep -rqE 'ExternalPurchase|ExternalPurchaseLink|ExternalPurchaseCustomLink' "${IOS_DIR:-.}" --include='*.swift' 2>/dev/null && ext_purchase=1
|
|
429
514
|
grep -rqE 'external-purchase' "${IOS_DIR:-.}" --include='*.entitlements' 2>/dev/null && ext_purchase=1
|
|
@@ -437,6 +522,7 @@ fi
|
|
|
437
522
|
# §3 catches "ATT framework imported but no usage string". This catches the more
|
|
438
523
|
# common rejection: shipping an ad / attribution SDK (or touching the IDFA) without
|
|
439
524
|
# ever presenting the ATT prompt. `tracking_sdk` and `att_used` are computed in §3.
|
|
525
|
+
set_rule "tracking-sdk-no-att"
|
|
440
526
|
if [[ -n "$tracking_sdk" ]]; then
|
|
441
527
|
att_present=""
|
|
442
528
|
[[ -n "$att_used" ]] && att_present=1
|
|
@@ -455,11 +541,12 @@ fi
|
|
|
455
541
|
# submission. Setting it (true/false) removes that friction. Only checked when an
|
|
456
542
|
# Info.plist exists; a modern app may auto-generate it, in which case the key lives
|
|
457
543
|
# in build settings, so we stay silent rather than nag.
|
|
544
|
+
set_rule "export-compliance"
|
|
458
545
|
if [[ -f "$INFO_PLIST" ]]; then
|
|
459
546
|
if grep -q "ITSAppUsesNonExemptEncryption" "$INFO_PLIST" 2>/dev/null; then
|
|
460
547
|
pass "export-compliance — ITSAppUsesNonExemptEncryption set in Info.plist"
|
|
461
548
|
else
|
|
462
|
-
warn "export-compliance — Info.plist has no ITSAppUsesNonExemptEncryption; set it (true/false) to skip the App Store Connect encryption-export prompt every submission"
|
|
549
|
+
warn "export-compliance — Info.plist has no ITSAppUsesNonExemptEncryption; set it (true/false) to skip the App Store Connect encryption-export prompt every submission" "$INFO_PLIST"
|
|
463
550
|
fi
|
|
464
551
|
fi
|
|
465
552
|
|
|
@@ -470,6 +557,7 @@ fi
|
|
|
470
557
|
# support_url.txt / privacy_url.txt / marketing_url.txt. Apple requires a working
|
|
471
558
|
# support URL, and a privacy policy URL for apps with accounts or IAP. Flag when
|
|
472
559
|
# they are absent across every locale, or contain an obvious placeholder.
|
|
560
|
+
set_rule "support-privacy-url"
|
|
473
561
|
if [[ -d "$META_DIR" ]]; then
|
|
474
562
|
support_found="" privacy_found=""
|
|
475
563
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
@@ -482,8 +570,9 @@ if [[ -d "$META_DIR" ]]; then
|
|
|
482
570
|
fi
|
|
483
571
|
url_ph=$(grep -rEnI 'example\.com|localhost|\bTODO\b|\bchangeme\b' "$META_DIR" --include='*_url.txt' 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
484
572
|
if [[ -n "$url_ph" ]]; then
|
|
485
|
-
|
|
486
|
-
|
|
573
|
+
url_ph_file="$(printf '%s\n' "$url_ph" | head -1 | cut -d: -f1)"
|
|
574
|
+
warn "2.3 Metadata URL — placeholder URL in fastlane metadata (replace before submitting):" "$url_ph_file"
|
|
575
|
+
detail "$url_ph"
|
|
487
576
|
fi
|
|
488
577
|
fi
|
|
489
578
|
|
|
@@ -495,7 +584,8 @@ fi
|
|
|
495
584
|
# domains, the privacy manifest and the App Privacy nutrition labels are probably
|
|
496
585
|
# incomplete. Soft "verify" wording (a crash-only Sentry may genuinely collect
|
|
497
586
|
# nothing), so WARN, never FAIL.
|
|
498
|
-
|
|
587
|
+
set_rule "analytics-privacyinfo-mismatch"
|
|
588
|
+
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" --include="*.swift" 2>/dev/null | head -1)
|
|
499
589
|
if [[ -n "$analytics_sdk" ]]; then
|
|
500
590
|
declared_data=""
|
|
501
591
|
if [[ -n "$PRIVACY_FILE" && -f "$PRIVACY_FILE" ]]; then
|
|
@@ -507,7 +597,7 @@ if [[ -n "$analytics_sdk" ]]; then
|
|
|
507
597
|
if [[ -n "$declared_data" ]]; then
|
|
508
598
|
pass "5.1.1 Privacy manifest — analytics SDK present and PrivacyInfo declares collected data"
|
|
509
599
|
else
|
|
510
|
-
warn "5.1.1 Privacy manifest — analytics SDK detected (e.g. $(basename "$analytics_sdk")) but PrivacyInfo declares no collected data types or tracking domains; verify your privacy manifest and App Privacy nutrition labels"
|
|
600
|
+
warn "5.1.1 Privacy manifest — analytics SDK detected (e.g. $(basename "$analytics_sdk")) but PrivacyInfo declares no collected data types or tracking domains; verify your privacy manifest and App Privacy nutrition labels" "$PRIVACY_FILE"
|
|
511
601
|
fi
|
|
512
602
|
fi
|
|
513
603
|
|
|
@@ -518,12 +608,14 @@ fi
|
|
|
518
608
|
# get rejected under 2.1 and look unfinished. Conservative, specific patterns to
|
|
519
609
|
# avoid flagging legitimate words. Overlaps the Phase 2 precheck "No placeholder
|
|
520
610
|
# text" rule, but this one is local and runs before any network call.
|
|
611
|
+
set_rule "placeholder-metadata"
|
|
521
612
|
if [[ -d "$META_DIR" ]]; then
|
|
522
613
|
ph_re='lorem ipsum|Lorem ipsum|\bTODO\b|\bFIXME\b|example\.com|placeholder|insert .* here|\bchangeme\b'
|
|
523
614
|
ph_hits=$(grep -rEnI "$ph_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -20)
|
|
524
615
|
if [[ -n "$ph_hits" ]]; then
|
|
525
|
-
|
|
526
|
-
|
|
616
|
+
ph_file="$(printf '%s\n' "$ph_hits" | head -1 | cut -d: -f1)"
|
|
617
|
+
warn "2.1 Metadata content — placeholder/dummy text in store metadata (looks unfinished; rejected under 2.1):" "$ph_file"
|
|
618
|
+
detail "$ph_hits"
|
|
527
619
|
fi
|
|
528
620
|
fi
|
|
529
621
|
|
|
@@ -534,6 +626,7 @@ fi
|
|
|
534
626
|
# third-party payment SDK (Stripe, Braintree, PayPal, …) is legitimate for
|
|
535
627
|
# PHYSICAL goods/services but a frequent rejection when used to sell digital
|
|
536
628
|
# content. We can't tell digital from physical statically, so this is advisory.
|
|
629
|
+
set_rule "thirdparty-payment-sdk"
|
|
537
630
|
if [[ -n "$IOS_DIR" ]]; then
|
|
538
631
|
payment_sdk=$(grep -rlE 'import Stripe|StripePaymentSheet|StripeApplePay|import Braintree|BTPaymentFlow|import PayPal|PayPalCheckout|PayPalNativeCheckout|import Square|SquareInAppPayments|import Adyen|AdyenComponents|RazorpaySDK|import Paddle' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
539
632
|
if [[ -n "$payment_sdk" ]]; then
|
|
@@ -547,6 +640,7 @@ fi
|
|
|
547
640
|
# Apps with UGC must provide: a content filter, a report mechanism, the ability
|
|
548
641
|
# to block abusive users, and published contact info. We detect a UGC signal and
|
|
549
642
|
# warn when no report/block/moderation affordance is found anywhere in the source.
|
|
643
|
+
set_rule "ugc-no-moderation"
|
|
550
644
|
if [[ -n "$IOS_DIR" ]]; then
|
|
551
645
|
ugc_signal=$(grep -rlE 'userGeneratedContent|\bUGC\b|StreamChat|MessageKit|SendbirdSDK|PubNub|postComment|submitComment|createPost|publishPost|uploadUserPhoto|uploadVideo' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
552
646
|
if [[ -n "$ugc_signal" ]]; then
|
|
@@ -563,9 +657,13 @@ fi
|
|
|
563
657
|
# ===================================================================
|
|
564
658
|
# NSAllowsArbitraryLoads=true turns off ATS for the whole app, weakening
|
|
565
659
|
# data-in-transit security and inviting a 1.6 / data-security question at review.
|
|
660
|
+
set_rule "ats-arbitrary-loads"
|
|
566
661
|
if [[ -f "$INFO_PLIST" ]]; then
|
|
567
|
-
|
|
568
|
-
|
|
662
|
+
# Anchor to the exact key: `NSAllowsArbitraryLoads</key>` (followed by `<`), so the
|
|
663
|
+
# narrower scoped exceptions NSAllowsArbitraryLoadsInWebContent / ...ForMedia — which
|
|
664
|
+
# do NOT disable ATS app-wide and are the recommended alternative — don't false-fire.
|
|
665
|
+
if awk '/NSAllowsArbitraryLoads</{getline; if ($0 ~ /<true/) print "ON"}' "$INFO_PLIST" 2>/dev/null | grep -q ON; then
|
|
666
|
+
warn "1.6 App Transport Security — NSAllowsArbitraryLoads=true in Info.plist disables ATS app-wide; prefer per-domain exceptions, and expect a data-security justification request at review (1.6)" "$INFO_PLIST"
|
|
569
667
|
fi
|
|
570
668
|
fi
|
|
571
669
|
|
|
@@ -577,6 +675,7 @@ fi
|
|
|
577
675
|
# Pay recurring API (PKRecurringPaymentRequest) so it does NOT conflate StoreKit
|
|
578
676
|
# auto-renew copy or a one-time PassKit payment with recurring Apple Pay. We don't
|
|
579
677
|
# try to detect the disclosure text (too noisy) — we flag it for manual verify.
|
|
678
|
+
set_rule "applepay-recurring-disclosure"
|
|
580
679
|
if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
581
680
|
warn "4.9 Apple Pay — recurring Apple Pay (PKRecurringPaymentRequest) detected; verify you disclose the renewal term, what's provided, the charges, and how to cancel before purchase (4.9)"
|
|
582
681
|
fi
|
|
@@ -586,6 +685,7 @@ fi
|
|
|
586
685
|
# ===================================================================
|
|
587
686
|
# Apple disallows custom review prompts and direct write-review links — apps must
|
|
588
687
|
# use the system SKStoreReviewController / requestReview API.
|
|
688
|
+
set_rule "custom-review-prompt"
|
|
589
689
|
if [[ -n "$IOS_DIR" ]]; then
|
|
590
690
|
review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
591
691
|
if [[ -n "$review_link" ]]; then
|
|
@@ -602,12 +702,14 @@ fi
|
|
|
602
702
|
# ===================================================================
|
|
603
703
|
# Marketing the app for things iOS apps can't actually do (virus/malware
|
|
604
704
|
# scanners, fake speed boosters) is a 2.3.1 removal vector.
|
|
705
|
+
set_rule "misleading-marketing"
|
|
605
706
|
if [[ -d "$META_DIR" ]]; then
|
|
606
707
|
mislead_re='virus scan|virus scanner|antivirus|anti-virus|malware (scan|remov|clean)|spyware remov|clean your (iphone|device)|speed booster|boost.*(speed|ram)|free money|guaranteed.*(win|prize)'
|
|
607
708
|
mislead_hits=$(grep -rEniI "$mislead_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
608
709
|
if [[ -n "$mislead_hits" ]]; then
|
|
609
|
-
|
|
610
|
-
|
|
710
|
+
mislead_file="$(printf '%s\n' "$mislead_hits" | head -1 | cut -d: -f1)"
|
|
711
|
+
warn "2.3.1 Misleading marketing — claims that often violate 2.3.1 (e.g. iOS virus/malware scanners, fake speed boosters) in metadata; verify the app truly delivers them or remove the claim:" "$mislead_file"
|
|
712
|
+
detail "$mislead_hits"
|
|
611
713
|
fi
|
|
612
714
|
fi
|
|
613
715
|
|
|
@@ -616,12 +718,17 @@ fi
|
|
|
616
718
|
# ===================================================================
|
|
617
719
|
# Terms implying a child audience in name/subtitle/keywords/description are
|
|
618
720
|
# reserved for the Kids Category (2.3.8 / 5.1.4).
|
|
721
|
+
set_rule "kids-wording"
|
|
619
722
|
if [[ -d "$META_DIR" ]]; then
|
|
620
723
|
kids_re='for kids|for children|for your (kid|child)|kids[[:space:]]?app|für kinder|para niños|pour enfants'
|
|
621
724
|
kids_hits=$(grep -rEniI "$kids_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
725
|
+
# Cross-gate with §39 (5.1.4): when an ads/analytics SDK is also linked, §39 fires the
|
|
726
|
+
# more specific Kids finding for the same wording signal — don't double-count it here
|
|
727
|
+
# (one root signal must not cost two WARNs against the 5-WARN YELLOW threshold).
|
|
728
|
+
if [[ -n "$kids_hits" && -z "${tracking_sdk:-}" && -z "${analytics_sdk:-}" ]]; then
|
|
729
|
+
kids_file="$(printf '%s\n' "$kids_hits" | head -1 | cut -d: -f1)"
|
|
730
|
+
warn "2.3.8 'For Kids/Children' wording — terms implying a child audience are reserved for the Kids Category (2.3.8); if not enrolled, remove them from name/subtitle/keywords/description:" "$kids_file"
|
|
731
|
+
detail "$kids_hits"
|
|
625
732
|
fi
|
|
626
733
|
fi
|
|
627
734
|
|
|
@@ -630,10 +737,11 @@ fi
|
|
|
630
737
|
# ===================================================================
|
|
631
738
|
# Keyboards must stay functional without full network access / "full access",
|
|
632
739
|
# and may only collect data to enhance the keyboard.
|
|
740
|
+
set_rule "keyboard-full-access"
|
|
633
741
|
kb_plist=$(grep -rlE 'com\.apple\.keyboard-service' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
634
742
|
if [[ -n "$kb_plist" ]]; then
|
|
635
743
|
if grep -A1 'RequestsOpenAccess' "$kb_plist" 2>/dev/null | grep -q '<true'; then
|
|
636
|
-
warn "4.4.1 Keyboard extension — RequestsOpenAccess=true in $kb_plist; keyboards must remain functional without full access and may only collect data to enhance the keyboard (4.4.1). Justify full access in your review notes."
|
|
744
|
+
warn "4.4.1 Keyboard extension — RequestsOpenAccess=true in $(basename "$kb_plist"); keyboards must remain functional without full access and may only collect data to enhance the keyboard (4.4.1). Justify full access in your review notes."
|
|
637
745
|
else
|
|
638
746
|
pass "4.4.1 Keyboard extension — present without requiring full access"
|
|
639
747
|
fi
|
|
@@ -644,6 +752,7 @@ fi
|
|
|
644
752
|
# ===================================================================
|
|
645
753
|
# Personal health information must not be stored in iCloud, and HealthKit data
|
|
646
754
|
# may not be used for advertising/marketing.
|
|
755
|
+
set_rule "health-icloud-sync"
|
|
647
756
|
if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
648
757
|
if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
649
758
|
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."
|
|
@@ -657,6 +766,7 @@ fi
|
|
|
657
766
|
# ===================================================================
|
|
658
767
|
# VPN apps must be offered by an organization account, declare data collection
|
|
659
768
|
# on-screen before use, and may not sell/share data.
|
|
769
|
+
set_rule "vpn-networkextension"
|
|
660
770
|
if [[ -n "$IOS_DIR" ]]; then
|
|
661
771
|
vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
662
772
|
if [[ -n "$vpn_use" ]]; then
|
|
@@ -671,6 +781,7 @@ fi
|
|
|
671
781
|
# or notes). We fire only on a credential-login signal (a password field or a
|
|
672
782
|
# Login/SignIn view), then look for demo creds in fastlane review_information or
|
|
673
783
|
# the reviewer-prep notes. Social-only logins are not gated here (too noisy).
|
|
784
|
+
set_rule "demo-account"
|
|
674
785
|
if [[ -n "$IOS_DIR" ]]; then
|
|
675
786
|
auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
676
787
|
[[ -z "$auth_signal" ]] && auth_signal=$(find "$IOS_DIR" "${PRUNE[@]}" \( -name '*Login*View*.swift' -o -name '*SignIn*View*.swift' \) 2>/dev/null | head -1)
|
|
@@ -697,6 +808,7 @@ fi
|
|
|
697
808
|
# Apps must be self-contained; native hot-patching frameworks (JSPatch, Rollout,
|
|
698
809
|
# DynamicCocoa) download code that changes features and are a removal vector.
|
|
699
810
|
# JS-bundle OTA for React Native (CodePush) is allowed, so we do NOT flag it.
|
|
811
|
+
set_rule "executable-code-download"
|
|
700
812
|
if [[ -n "$IOS_DIR" ]]; then
|
|
701
813
|
hotcode=$(grep -rlE 'import JSPatch|JSPatch\.|[Jj][Ss][Pp]atch|import Rollout|Rollout\.|rollout\.io|DynamicCocoa|import SwiftPatch' "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -1)
|
|
702
814
|
if [[ -n "$hotcode" ]]; then
|
|
@@ -710,6 +822,7 @@ fi
|
|
|
710
822
|
# Declare only the UIBackgroundModes the app actually uses; a mode declared with
|
|
711
823
|
# no matching API is a frequent rejection. We parse the array and check each
|
|
712
824
|
# declared mode against its framework/API in Swift.
|
|
825
|
+
set_rule "background-modes-unused"
|
|
713
826
|
if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
|
|
714
827
|
modes=$(awk '/<key>UIBackgroundModes<\/key>/{f=1;next} f&&/<\/array>/{f=0} f&&/<string>/{gsub(/.*<string>|<\/string>.*/,""); print}' "$INFO_PLIST" 2>/dev/null)
|
|
715
828
|
if [[ -n "$modes" ]]; then
|
|
@@ -718,16 +831,16 @@ if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
|
|
|
718
831
|
[[ -z "$m" ]] && continue
|
|
719
832
|
case "$m" in
|
|
720
833
|
location) grep -rqE 'CLLocationManager|CoreLocation' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused location" ;;
|
|
721
|
-
audio) grep -rqE 'AVAudioSession|AVPlayer|AVAudioPlayer|AVQueuePlayer|AVAudioEngine' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused audio" ;;
|
|
834
|
+
audio) grep -rqE 'AVAudioSession|AVPlayer|AVAudioPlayer|AVQueuePlayer|AVAudioEngine|AVKit|VideoPlayer|AVPlayerViewController|MPMusicPlayerController|MPNowPlayingInfoCenter' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused audio" ;;
|
|
722
835
|
voip) grep -rqE 'PushKit|PKPushRegistry|CallKit|CXProvider' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused voip" ;;
|
|
723
|
-
fetch) grep -rqE 'BGAppRefreshTask|BackgroundTasks|setMinimumBackgroundFetchInterval|performFetchWithCompletionHandler' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused fetch" ;;
|
|
724
|
-
processing) grep -rqE 'BGProcessingTask|BackgroundTasks' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused processing" ;;
|
|
836
|
+
fetch) grep -rqE 'BGAppRefreshTask|BGTaskScheduler|BackgroundTasks|setMinimumBackgroundFetchInterval|performFetchWithCompletionHandler' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused fetch" ;;
|
|
837
|
+
processing) grep -rqE 'BGProcessingTask|BGTaskScheduler|BackgroundTasks' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused processing" ;;
|
|
725
838
|
bluetooth-central|bluetooth-peripheral) grep -rqE 'CoreBluetooth|CBCentralManager|CBPeripheralManager' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused $m" ;;
|
|
726
839
|
remote-notification) grep -rqE 'didReceiveRemoteNotification|UNUserNotificationCenter|registerForRemoteNotifications' "$IOS_DIR" --include="*.swift" 2>/dev/null || unused="$unused remote-notification" ;;
|
|
727
840
|
esac
|
|
728
841
|
done <<< "$modes"
|
|
729
842
|
if [[ -n "$unused" ]]; then
|
|
730
|
-
warn "2.5.4 Background modes —$unused declared in UIBackgroundModes but no matching API usage found in Swift; declare only the background modes the app actually uses (2.5.4)"
|
|
843
|
+
warn "2.5.4 Background modes —$unused declared in UIBackgroundModes but no matching API usage found in Swift; declare only the background modes the app actually uses (2.5.4)" "$INFO_PLIST"
|
|
731
844
|
else
|
|
732
845
|
pass "2.5.4 Background modes — declared modes have matching API usage"
|
|
733
846
|
fi
|
|
@@ -737,6 +850,7 @@ fi
|
|
|
737
850
|
# ===================================================================
|
|
738
851
|
# §34 — 3.1.5(a) Cryptocurrency wallet / exchange / mining
|
|
739
852
|
# ===================================================================
|
|
853
|
+
set_rule "crypto-wallet-mining"
|
|
740
854
|
if [[ -n "$IOS_DIR" ]]; then
|
|
741
855
|
crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
742
856
|
if [[ -n "$crypto_sdk" ]]; then
|
|
@@ -750,6 +864,7 @@ fi
|
|
|
750
864
|
# A thin WKWebView wrapper around a website is rejected under minimum
|
|
751
865
|
# functionality. Heuristic: WKWebView present in a project with very few Swift
|
|
752
866
|
# files. WARN (verify) — this is the most false-positive-prone of the batch.
|
|
867
|
+
set_rule "webview-wrapper"
|
|
753
868
|
if [[ -n "$IOS_DIR" ]]; then
|
|
754
869
|
if grep -rqE 'WKWebView' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
755
870
|
swift_n=$(find "$IOS_DIR" "${PRUNE[@]}" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')
|
|
@@ -762,6 +877,7 @@ fi
|
|
|
762
877
|
# ===================================================================
|
|
763
878
|
# §36 — 4.2.7 Remote desktop / host-mirroring
|
|
764
879
|
# ===================================================================
|
|
880
|
+
set_rule "remote-desktop"
|
|
765
881
|
if [[ -n "$IOS_DIR" ]]; then
|
|
766
882
|
remote_desktop=$(grep -rlE 'import libvncclient|LibVNC|VNCClient|RDPSession|RDPKit|RemoteDesktopClient|import FreeRDP|JumpDesktop' "$IOS_DIR" --include="*.swift" --include="*.m" 2>/dev/null | head -1)
|
|
767
883
|
if [[ -n "$remote_desktop" ]]; then
|
|
@@ -772,9 +888,10 @@ fi
|
|
|
772
888
|
# ===================================================================
|
|
773
889
|
# §37 — 4.4.2 Safari extension / content blocker
|
|
774
890
|
# ===================================================================
|
|
891
|
+
set_rule "safari-extension"
|
|
775
892
|
safari_ext=$(grep -rlE 'com\.apple\.Safari\.(content-blocker|web-extension|extension)' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
776
893
|
if [[ -n "$safari_ext" ]]; then
|
|
777
|
-
warn "4.4.2 Safari extension — a Safari content-blocker / web extension was detected ($safari_ext); it must use the extension APIs as intended, do only what it declares, and not include hidden analytics/ads or track without consent (4.4.2). Verify."
|
|
894
|
+
warn "4.4.2 Safari extension — a Safari content-blocker / web extension was detected ($(basename "$safari_ext")); it must use the extension APIs as intended, do only what it declares, and not include hidden analytics/ads or track without consent (4.4.2). Verify."
|
|
778
895
|
fi
|
|
779
896
|
|
|
780
897
|
# ===================================================================
|
|
@@ -783,6 +900,7 @@ fi
|
|
|
783
900
|
# Apple 5.1.1(v): apps that support account creation must also let users delete
|
|
784
901
|
# their account from within the app. Detect account-creation signals, then look
|
|
785
902
|
# for an in-app deletion path. Deletion via a web page is missed → WARN, not FAIL.
|
|
903
|
+
set_rule "account-no-delete"
|
|
786
904
|
if [[ -n "$IOS_DIR" ]]; then
|
|
787
905
|
signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
788
906
|
if [[ -n "$signup" ]]; then
|
|
@@ -801,6 +919,7 @@ fi
|
|
|
801
919
|
# must include a parental gate. We fire when the metadata targets a child
|
|
802
920
|
# audience AND an ad/analytics SDK is linked. tracking_sdk/analytics_sdk come
|
|
803
921
|
# from §3/§19.
|
|
922
|
+
set_rule "kids-ads-analytics"
|
|
804
923
|
if [[ -d "$META_DIR" && -n "$IOS_DIR" ]]; then
|
|
805
924
|
if grep -rqEiI 'for kids|for children|kids[[:space:]]?app|für kinder|para niños|pour enfants' "$META_DIR" 2>/dev/null; then
|
|
806
925
|
if [[ -n "$tracking_sdk" || -n "$analytics_sdk" ]]; then
|
|
@@ -812,18 +931,23 @@ fi
|
|
|
812
931
|
# ===================================================================
|
|
813
932
|
# §40 — 5.3.4 Real-money gambling
|
|
814
933
|
# ===================================================================
|
|
934
|
+
set_rule "realmoney-gambling"
|
|
815
935
|
if [[ -d "$META_DIR" ]]; then
|
|
816
936
|
gamble_re='real[ -]?money|gambling|casino|sportsbook|sports[ -]?betting|place[ -].*bets|wager(ing)?|roulette.*real'
|
|
817
937
|
gamble_hits=$(grep -rEniI "$gamble_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
818
938
|
if [[ -n "$gamble_hits" ]]; then
|
|
819
|
-
|
|
820
|
-
|
|
939
|
+
gamble_first="$(printf '%s\n' "$gamble_hits" | head -1)" # "path:line:match"
|
|
940
|
+
gamble_file="${gamble_first%%:*}"
|
|
941
|
+
gamble_rest="${gamble_first#*:}"; gamble_line="${gamble_rest%%:*}"
|
|
942
|
+
warn "5.3.4 Gambling — real-money gaming language in metadata; real-money gambling/lotteries need the proper licenses, must be geo-restricted to permitted regions, and must be free on the App Store (5.3.4):" "$gamble_file" "$gamble_line"
|
|
943
|
+
detail "$gamble_hits"
|
|
821
944
|
fi
|
|
822
945
|
fi
|
|
823
946
|
|
|
824
947
|
# ===================================================================
|
|
825
948
|
# §41 — 5.5 Mobile Device Management
|
|
826
949
|
# ===================================================================
|
|
950
|
+
set_rule "mdm"
|
|
827
951
|
if [[ -n "$IOS_DIR" ]]; then
|
|
828
952
|
mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
829
953
|
[[ -z "$mdm_sig" ]] && mdm_sig=$(grep -rlE 'com\.apple\.configuration\.managed' --include='*.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
@@ -833,3 +957,7 @@ if [[ -n "$IOS_DIR" ]]; then
|
|
|
833
957
|
fi
|
|
834
958
|
|
|
835
959
|
echo "---END-OF-SCAN---"
|
|
960
|
+
if [[ "$FORMAT" == text && "${_SUPPRESSED_COUNT:-0}" -gt 0 ]]; then
|
|
961
|
+
printf '(%s finding(s) suppressed via .precheck-ignore)\n' "$_SUPPRESSED_COUNT"
|
|
962
|
+
fi
|
|
963
|
+
if [[ "$FORMAT" == json ]]; then exec 1>&4 4>&-; render_json; fi
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# suppress.sh — .precheck-ignore + inline suppression for scan.sh.
|
|
3
|
+
# Sourced by scan.sh AFTER findings.sh (needs rule_slug). Bash 3.2: no associative arrays.
|
|
4
|
+
|
|
5
|
+
_SUPP_RULES="" # rule-ids suppressed everywhere, one per line
|
|
6
|
+
_SUPP_RULE_PATH="" # "rule<TAB>glob" per line
|
|
7
|
+
_SUPP_PATHS="" # path globs excluded from scanning, one per line (trailing / stripped)
|
|
8
|
+
_SUPP_REASON="" # set by is_suppressed on a hit
|
|
9
|
+
|
|
10
|
+
# _is_catalog_rule <token> -> 0 if token is a known rule slug.
|
|
11
|
+
_is_catalog_rule() {
|
|
12
|
+
local n s
|
|
13
|
+
n=1
|
|
14
|
+
while [[ $n -le 41 ]]; do
|
|
15
|
+
s="$(rule_slug "$n")"
|
|
16
|
+
[[ "$s" == "$1" ]] && return 0
|
|
17
|
+
n=$((n + 1))
|
|
18
|
+
done
|
|
19
|
+
return 1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# load_precheck_ignore [root]
|
|
23
|
+
load_precheck_ignore() {
|
|
24
|
+
local root="${1:-.}" file line t1 t2
|
|
25
|
+
file="$root/.precheck-ignore"
|
|
26
|
+
_SUPP_RULES=""; _SUPP_RULE_PATH=""; _SUPP_PATHS=""
|
|
27
|
+
[[ -f "$file" ]] || return 0
|
|
28
|
+
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
29
|
+
line="${line%%#*}" # strip trailing comment
|
|
30
|
+
line="$(printf '%s' "$line" | awk '{$1=$1;print}')" # trim ends, collapse ws
|
|
31
|
+
[[ -z "$line" ]] && continue
|
|
32
|
+
t1="$(printf '%s' "$line" | awk '{print $1}')"
|
|
33
|
+
t2="$(printf '%s' "$line" | awk '{print $2}')"
|
|
34
|
+
if _is_catalog_rule "$t1"; then
|
|
35
|
+
if [[ -n "$t2" ]]; then
|
|
36
|
+
_SUPP_RULE_PATH="${_SUPP_RULE_PATH}${t1} ${t2%/}
|
|
37
|
+
"
|
|
38
|
+
else
|
|
39
|
+
_SUPP_RULES="${_SUPP_RULES}${t1}
|
|
40
|
+
"
|
|
41
|
+
fi
|
|
42
|
+
elif [[ "$t1" == */* || "$t1" == *.* || "$t1" == *"*"* ]]; then
|
|
43
|
+
_SUPP_PATHS="${_SUPP_PATHS}${t1%/}
|
|
44
|
+
"
|
|
45
|
+
else
|
|
46
|
+
printf 'suppress: unknown rule-id %s in .precheck-ignore (ignored)\n' "$t1" >&2
|
|
47
|
+
fi
|
|
48
|
+
done < "$file"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# precheck_prune_globs -> one path glob per line (for scan.sh PRUNE/GREP_PRUNE).
|
|
52
|
+
precheck_prune_globs() { printf '%s' "$_SUPP_PATHS"; }
|
|
53
|
+
|
|
54
|
+
# _inline_marker <line-text> <rule> -> 0 if a real comment marker suppresses <rule>.
|
|
55
|
+
_inline_marker() {
|
|
56
|
+
local text="$1" rule="$2" spec
|
|
57
|
+
printf '%s' "$text" | grep -qE '(//|#|<!--)[[:space:]]*precheck:ignore' || return 1
|
|
58
|
+
spec="$(printf '%s' "$text" | sed -nE 's/.*precheck:ignore[[:space:]]*([a-z][a-z0-9-]*).*/\1/p')"
|
|
59
|
+
[[ -z "$spec" ]] && return 0 # bare marker suppresses any rule
|
|
60
|
+
[[ "$spec" == "$rule" ]] # scoped marker must match
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# is_suppressed <rule> <file> <line> -> 0 if suppressed (+ _SUPP_REASON), else 1.
|
|
64
|
+
is_suppressed() {
|
|
65
|
+
local rule="$1" file="${2:-}" line="${3:-}" r g target
|
|
66
|
+
_SUPP_REASON=""
|
|
67
|
+
if [[ -n "$rule" ]] && printf '%s\n' "$_SUPP_RULES" | grep -qxF "$rule"; then
|
|
68
|
+
_SUPP_REASON="rule:$rule"; return 0
|
|
69
|
+
fi
|
|
70
|
+
if [[ -n "$rule" && -n "$file" && -n "$_SUPP_RULE_PATH" ]]; then
|
|
71
|
+
while IFS=' ' read -r r g; do
|
|
72
|
+
[[ -z "$r" ]] && continue
|
|
73
|
+
if [[ "$r" == "$rule" ]]; then
|
|
74
|
+
# shellcheck disable=SC2254
|
|
75
|
+
case "$file" in
|
|
76
|
+
$g|*/$g|$g/*|*/$g/*) _SUPP_REASON="rule-path:$rule:$g"; return 0 ;;
|
|
77
|
+
esac
|
|
78
|
+
fi
|
|
79
|
+
done <<INNER
|
|
80
|
+
$_SUPP_RULE_PATH
|
|
81
|
+
INNER
|
|
82
|
+
fi
|
|
83
|
+
if [[ -n "$file" && -n "$line" && -f "$file" ]]; then
|
|
84
|
+
target="$(sed -n "${line}p" "$file" 2>/dev/null)"
|
|
85
|
+
if _inline_marker "$target" "$rule"; then _SUPP_REASON="inline"; return 0; fi
|
|
86
|
+
if [[ "$line" -gt 1 ]]; then
|
|
87
|
+
target="$(sed -n "$((line - 1))p" "$file" 2>/dev/null)"
|
|
88
|
+
if _inline_marker "$target" "$rule"; then _SUPP_REASON="inline-above"; return 0; fi
|
|
89
|
+
fi
|
|
90
|
+
fi
|
|
91
|
+
return 1
|
|
92
|
+
}
|