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 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.5.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.5.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 (22 checks)](#phase-4-pierre-deep-review-22-semantic-checks)
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. Checks **1–22**
5
- (minus Tier B items below) are high-confidence (Tier A); checks **4, 5, 7, 10, 15, and 28** are
6
- heuristic advisory (Tier B v1 higher false-positive risk, still useful pre-submit signals).
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
- fail() { echo "FAIL: $1"; }
36
- warn() { echo "WARN: $1"; }
37
- pass() { echo "PASS: $1"; }
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
- echo "$hits" | sed 's/^/ /'
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
- warn "2.3.3 Screenshots — screenshots dir not found (set .screenshotsDir if you manage them in-repo)"
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 'import StoreKit|RevenueCat|Purchases\.|Product\(for:|AppStore\.|SKProduct|StoreKit2' "$IOS_DIR" --include="*.swift" 2>/dev/null && iap_detected=1
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
- fail "2.5.1 Private/Deprecated API:"
384
- echo "$banned_hits" | sed 's/^/ /'
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
- tab_count=$(grep -rcE 'TabView|NavigationStack|NavigationSplitView' "$IOS_DIR" --include="*.swift" 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
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
- warn "2.3 Metadata URL placeholder URL in fastlane metadata (replace before submitting):"
486
- echo "$url_ph" | sed 's/^/ /'
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
- analytics_sdk=$(grep -rlE 'FirebaseAnalytics|import Firebase|Amplitude|Mixpanel|import Sentry|Segment|Bugsnag|AppCenterAnalytics|Datadog' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
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
- warn "2.1 Metadata content placeholder/dummy text in store metadata (looks unfinished; rejected under 2.1):"
526
- echo "$ph_hits" | sed 's/^/ /'
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
- if awk '/NSAllowsArbitraryLoads/{getline; if ($0 ~ /<true/) print "ON"}' "$INFO_PLIST" 2>/dev/null | grep -q ON; then
568
- warn "1.6 App Transport Security — NSAllowsArbitraryLoads=true in Info.plist disables ATS app-wide; prefer per-domain exceptions, and expect a data-security justification request at review (1.6)"
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
- warn "2.3.1 Misleading marketing claims that often violate 2.3.1 (e.g. iOS virus/malware scanners, fake speed boosters) in metadata; verify the app truly delivers them or remove the claim:"
610
- echo "$mislead_hits" | sed 's/^/ /'
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
- if [[ -n "$kids_hits" ]]; then
623
- warn "2.3.8 'For Kids/Children' wording — terms implying a child audience are reserved for the Kids Category (2.3.8); if not enrolled, remove them from name/subtitle/keywords/description:"
624
- echo "$kids_hits" | sed 's/^/ /'
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
- warn "5.3.4 Gambling real-money gaming language in metadata; real-money gambling/lotteries need the proper licenses, must be geo-restricted to permitted regions, and must be free on the App Store (5.3.4):"
820
- echo "$gamble_hits" | sed 's/^/ /'
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
+ }