appstore-precheck 1.5.2 → 1.7.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 +51 -0
- package/README.md +57 -0
- package/package.json +2 -2
- package/skills/appstore-precheck/SKILL.md +6 -1
- package/skills/appstore-precheck/scripts/findings.sh +86 -0
- package/skills/appstore-precheck/scripts/project-model.sh +190 -0
- package/skills/appstore-precheck/scripts/scan.sh +175 -39
- package/skills/appstore-precheck/scripts/suppress.sh +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,57 @@
|
|
|
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.7.0] - 2026-07-02
|
|
9
|
+
|
|
10
|
+
Smarter analysis (roadmap #2b): the scanner now resolves the iOS source directory
|
|
11
|
+
and `Info.plist` from the Xcode **project model** (`.pbxproj`) instead of a pure
|
|
12
|
+
grep heuristic, eliminating the dominant remaining false-positive source in
|
|
13
|
+
monorepo / SPM / multi-target layouts. Zero new runtime dependencies (pure
|
|
14
|
+
bash + awk), READ-ONLY preserved, and default text output stays byte-identical for
|
|
15
|
+
any repo without a `.pbxproj`.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Project-model detection** (`skills/appstore-precheck/scripts/project-model.sh`):
|
|
19
|
+
parses `.pbxproj` to find the primary `application`-type target and resolve its
|
|
20
|
+
source dir + `INFOPLIST_FILE` authoritatively, across ALL `.xcodeproj` in a
|
|
21
|
+
monorepo. `detect_ios_dir` now chains: config `.iosSourceDir` > project-model
|
|
22
|
+
parse > the original grep heuristic (unchanged, kept as fallback).
|
|
23
|
+
- Per-target `INFOPLIST_FILE` attribution via the build-config graph
|
|
24
|
+
(target → `buildConfigurationList` → `XCBuildConfiguration`), used as a last
|
|
25
|
+
resort so already-correct apps are untouched; unexpanded build-variable paths
|
|
26
|
+
(`$(SRCROOT)` etc.) are guarded, and app targets under vendored paths
|
|
27
|
+
(`ThirdParty`/`Vendor`) are deprioritized so a vendored sample app never wins.
|
|
28
|
+
|
|
29
|
+
### Measured impact (18-app open-source panel, candidate/directional labels)
|
|
30
|
+
- Corrects detection on `wikipedia-ios`, `pocket-casts-ios`, `cwa-app-ios`
|
|
31
|
+
(now read their real custom-named plists) and `brave-ios` (real app over a
|
|
32
|
+
vendored sample). `usage-description-crosscheck` false positives 9 → 6, with
|
|
33
|
+
content-grounded findings surfaced by finally reading the correct plist; zero
|
|
34
|
+
true-positive loss. See `docs/fp-reduction-report.md`.
|
|
35
|
+
|
|
36
|
+
## [1.6.0] - 2026-07-01
|
|
37
|
+
|
|
38
|
+
Measurement release: structured findings, suppression, and a published
|
|
39
|
+
precision/recall scorecard, plus a measurement-driven false-positive reduction
|
|
40
|
+
round. Default text output stays byte-identical (verified across 11 fixtures);
|
|
41
|
+
every behavior change is gated by TDD and the synthetic-corpus `--check`.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- **`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.
|
|
45
|
+
- **`.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).
|
|
46
|
+
- **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.
|
|
47
|
+
- File/line threading into locatable checks (surfaced in JSON; text output unchanged).
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
- **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):
|
|
51
|
+
- **Analytics detection** now requires an import/API-qualified form, so a bare `Segment` substring (e.g. `UISegmentedControl`) no longer trips 5.1.1.
|
|
52
|
+
- **IAP gate** requires a real purchase API, ignoring `SKStoreReviewController` / `SKAdNetwork` / the `AppStore.` namespace.
|
|
53
|
+
- **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.
|
|
54
|
+
- **Minimum-functionality navigation** detects UIKit / `NavigationView` / React-Navigation repo-wide.
|
|
55
|
+
- **Screenshots-per-locale**: no in-repo screenshots directory is an advisory PASS (managed in App Store Connect), not a WARN.
|
|
56
|
+
|
|
6
57
|
## [1.5.2] - 2026-06-30
|
|
7
58
|
|
|
8
59
|
### Fixed
|
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.7.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.7.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
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# project-model.sh — resolve the primary app target's source dir + Info.plist from
|
|
3
|
+
# an Xcode project model (.pbxproj), authoritatively and dependency-free.
|
|
4
|
+
# Sourced by scan.sh. Pure bash + awk. READ-ONLY. Bash 3.2 compatible.
|
|
5
|
+
|
|
6
|
+
# pm_app_targets <pbxproj> -> names of targets whose productType is the application type.
|
|
7
|
+
# .pbxproj PBXNativeTarget blocks list `name` before `productType`; the block closes
|
|
8
|
+
# with a bare `};`. Nested lists close with `)`, never `};`, so `};` reliably ends a block.
|
|
9
|
+
pm_app_targets() {
|
|
10
|
+
awk '
|
|
11
|
+
/isa = PBXNativeTarget;/ { in_t=1; name=""; pt=""; next }
|
|
12
|
+
in_t && /^[[:space:]]*name = / {
|
|
13
|
+
l=$0; sub(/^[[:space:]]*name = /,"",l); sub(/;[[:space:]]*$/,"",l); gsub(/^"|"$/,"",l); name=l
|
|
14
|
+
}
|
|
15
|
+
in_t && /^[[:space:]]*productType = / {
|
|
16
|
+
l=$0; sub(/^[[:space:]]*productType = /,"",l); sub(/;[[:space:]]*$/,"",l); gsub(/^"|"$/,"",l); pt=l
|
|
17
|
+
}
|
|
18
|
+
in_t && /^[[:space:]]*};[[:space:]]*$/ {
|
|
19
|
+
if (pt == "com.apple.product-type.application" && name != "") print name
|
|
20
|
+
in_t=0
|
|
21
|
+
}
|
|
22
|
+
' "$1"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# pm_infoplist_files <pbxproj> -> every INFOPLIST_FILE value, unquoted, sorted, deduped.
|
|
26
|
+
pm_infoplist_files() {
|
|
27
|
+
awk '/^[[:space:]]*INFOPLIST_FILE = /{
|
|
28
|
+
l=$0; sub(/^[[:space:]]*INFOPLIST_FILE = /,"",l); sub(/;[[:space:]]*$/,"",l); gsub(/^"|"$/,"",l); print l
|
|
29
|
+
}' "$1" | sort -u
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# pm_target_infoplist <pbxproj> <target> -> the target's own INFOPLIST_FILE via its
|
|
33
|
+
# build configurations (first non-empty), or non-zero. Handles quoted paths with spaces.
|
|
34
|
+
#
|
|
35
|
+
# A repo may declare INFOPLIST_FILE for several targets whose leading path
|
|
36
|
+
# component does not match the target name (e.g. Client's plist lives under
|
|
37
|
+
# "iOS/Supporting Files"). pm_infoplist_files() alone cannot attribute a given
|
|
38
|
+
# plist to a specific target, so this walks the actual pbxproj graph:
|
|
39
|
+
# PBXNativeTarget "name = X" -> buildConfigurationList = <UUID> -> the
|
|
40
|
+
# XCConfigurationList block for that UUID -> its buildConfigurations list ->
|
|
41
|
+
# each XCBuildConfiguration block, looking for INFOPLIST_FILE.
|
|
42
|
+
#
|
|
43
|
+
# NOTE: this returns the FIRST build config's plist, which is only reliable
|
|
44
|
+
# when all configs agree. Per-configuration plists (Debug vs Release) can
|
|
45
|
+
# legitimately differ (e.g. cwa-app-ios's ENA target), so pm_resolve() only
|
|
46
|
+
# calls this as a LAST RESORT — after the leading-component match and the
|
|
47
|
+
# GENERATE dir-name branch have both failed. Callers must also guard against
|
|
48
|
+
# unexpanded Xcode build variables (e.g. "$(SRCROOT)/...") in the returned
|
|
49
|
+
# path, since those can never resolve to a real file on disk.
|
|
50
|
+
pm_target_infoplist() {
|
|
51
|
+
local pbx="$1" target="$2" cl u ip
|
|
52
|
+
cl="$(awk -v t="$target" '
|
|
53
|
+
/isa = PBXNativeTarget;/{inb=1;nm="";c="";next}
|
|
54
|
+
inb&&/^[[:space:]]*name = /{l=$0;sub(/^[[:space:]]*name = /,"",l);sub(/;[[:space:]]*$/,"",l);gsub(/^"|"$/,"",l);nm=l}
|
|
55
|
+
inb&&/^[[:space:]]*buildConfigurationList = /{l=$0;sub(/^[[:space:]]*buildConfigurationList = /,"",l);sub(/;[[:space:]]*$/,"",l);sub(/ .*/,"",l);c=l}
|
|
56
|
+
inb&&/^[[:space:]]*};[[:space:]]*$/{if(nm==t&&c!=""){print c;exit}inb=0}
|
|
57
|
+
' "$pbx")"
|
|
58
|
+
[[ -z "$cl" ]] && return 1
|
|
59
|
+
# Match only the block-OPENING line for a UUID (ends in "{"), never a
|
|
60
|
+
# reference to that UUID inside some other list (e.g. the UUID also
|
|
61
|
+
# appears as a bare list item "CFG1 /* Debug */," inside the
|
|
62
|
+
# buildConfigurations array itself, which would otherwise false-match).
|
|
63
|
+
local bcs; bcs="$(awk -v cl="$cl" '
|
|
64
|
+
$0 ~ ("^[[:space:]]*" cl "[[:space:]]") && /\{[[:space:]]*$/ {inb=1}
|
|
65
|
+
inb&&/buildConfigurations = \(/{inl=1;next}
|
|
66
|
+
inb&&inl&&/\)/{exit}
|
|
67
|
+
inb&&inl{l=$0;sub(/\/\*.*/,"",l);gsub(/[[:space:]]/,"",l);sub(/,$/,"",l);if(l!="")print l}
|
|
68
|
+
' "$pbx")"
|
|
69
|
+
[[ -z "$bcs" ]] && return 1
|
|
70
|
+
for u in $bcs; do
|
|
71
|
+
ip="$(awk -v u="$u" '
|
|
72
|
+
$0 ~ ("^[[:space:]]*" u "[[:space:]]") && /\{[[:space:]]*$/ {inb=1}
|
|
73
|
+
inb&&/^[[:space:]]*INFOPLIST_FILE = /{l=$0;sub(/^[[:space:]]*INFOPLIST_FILE = /,"",l);sub(/;[[:space:]]*$/,"",l);gsub(/^"|"$/,"",l);print l;exit}
|
|
74
|
+
inb&&/^[[:space:]]*};[[:space:]]*$/{exit}
|
|
75
|
+
' "$pbx")"
|
|
76
|
+
[[ -n "$ip" ]] && { printf '%s\n' "$ip"; return 0; }
|
|
77
|
+
done
|
|
78
|
+
return 1
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Vendored dirs whose .xcodeproj must never win detection.
|
|
82
|
+
PM_PRUNE_DIRS='node_modules|Pods|Carthage|\.build|DerivedData|\.git'
|
|
83
|
+
|
|
84
|
+
# App targets whose project lives under a vendored/sample path are deprioritized:
|
|
85
|
+
# they only win when no primary (non-vendored) app target exists. This is a
|
|
86
|
+
# deprioritization heuristic (mirrors scan.sh's NONAPP_TARGET), bounded on
|
|
87
|
+
# purpose: a sample app under a clearly-vendored path (ThirdParty/Vendor)
|
|
88
|
+
# loses to a primary app, but the heuristic deliberately does NOT match
|
|
89
|
+
# Demo/Sample/Example-named dirs, because those often hold a library repo's
|
|
90
|
+
# real deliverable app rather than a throwaway sample (Pods/Carthage/
|
|
91
|
+
# node_modules are already fully pruned by PM_PRUNE_DIRS in
|
|
92
|
+
# pm_find_pbxprojs, so they need not be listed here either).
|
|
93
|
+
PM_SAMPLE_PATH='(^|/)(ThirdParty|Vendor(ed)?)(/|$)'
|
|
94
|
+
|
|
95
|
+
# pm_find_pbxprojs <root> -> all project.pbxproj under *.xcodeproj (pruned), deterministic order.
|
|
96
|
+
pm_find_pbxprojs() {
|
|
97
|
+
local root="${1:-.}"
|
|
98
|
+
find "$root" -name 'project.pbxproj' -path '*.xcodeproj/*' 2>/dev/null \
|
|
99
|
+
| grep -Ev "/($PM_PRUNE_DIRS)/" \
|
|
100
|
+
| LC_ALL=C sort
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# pm_resolve <root> -> "DIR<TAB>PLIST" (ROOT-relative; PLIST may be empty) for the
|
|
104
|
+
# primary app target, or non-zero with no output.
|
|
105
|
+
#
|
|
106
|
+
# A monorepo may contain several .xcodeproj (samples, sub-projects, the real
|
|
107
|
+
# app). The shallowest one is not necessarily the real app, so every pbxproj
|
|
108
|
+
# is resolved on its own terms (against its own SRCROOT-relative projdir) and
|
|
109
|
+
# the single global best — most *.swift sources — wins across all of them.
|
|
110
|
+
pm_resolve() {
|
|
111
|
+
local root="${1:-.}" pbx rel projdir apps plists app plist dir n cand_dir cand_plist
|
|
112
|
+
# "best" is the primary (non-vendored/sample) bucket; "alt" collects app
|
|
113
|
+
# targets whose project lives under a vendored/sample path (PM_SAMPLE_PATH).
|
|
114
|
+
# alt only wins when no primary candidate exists at all — see the tail.
|
|
115
|
+
local best="" best_plist="" best_n=-1
|
|
116
|
+
local alt="" alt_plist="" alt_n=-1
|
|
117
|
+
local found=0
|
|
118
|
+
# Normalize a trailing slash so the ROOT-relative strip below reliably matches
|
|
119
|
+
# (root="/" strips to "" and must stay "/"; root="." is untouched).
|
|
120
|
+
root="${root%/}"; [[ -z "$root" ]] && root="/"
|
|
121
|
+
while IFS= read -r pbx; do
|
|
122
|
+
[[ -z "$pbx" ]] && continue
|
|
123
|
+
apps="$(pm_app_targets "$pbx")"; [[ -z "$apps" ]] && continue
|
|
124
|
+
found=1
|
|
125
|
+
# ROOT-relative dir that contains this .xcodeproj (SRCROOT). Its
|
|
126
|
+
# INFOPLIST_FILE paths and app source dir are relative to this.
|
|
127
|
+
rel="${pbx#"$root"/}" # e.g. ios/App.xcodeproj/project.pbxproj
|
|
128
|
+
projdir="$(dirname "$(dirname "$rel")")"; projdir="${projdir#./}"
|
|
129
|
+
[[ "$projdir" == "." ]] && projdir=""
|
|
130
|
+
plists="$(pm_infoplist_files "$pbx")"
|
|
131
|
+
while IFS= read -r app; do
|
|
132
|
+
[[ -z "$app" ]] && continue
|
|
133
|
+
# Priority 1: a declared plist whose leading path component equals the
|
|
134
|
+
# app target name.
|
|
135
|
+
plist="$(printf '%s\n' "$plists" | awk -v a="$app" -F/ '$1==a{print; exit}')"
|
|
136
|
+
if [[ -n "$plist" ]]; then
|
|
137
|
+
dir="$(dirname "$plist")"
|
|
138
|
+
else
|
|
139
|
+
# Priority 2 (GENERATE_INFOPLIST_FILE): no leading-component plist.
|
|
140
|
+
# Use the dir named after the target.
|
|
141
|
+
dir="$(cd "$root${projdir:+/$projdir}" 2>/dev/null && \
|
|
142
|
+
find . -type d -name "$app" 2>/dev/null | sed 's#^\./##' \
|
|
143
|
+
| awk '{print length, $0}' | sort -n | head -1 | cut -d' ' -f2-)"
|
|
144
|
+
if [[ -z "$dir" ]]; then
|
|
145
|
+
# Priority 3 (LAST RESORT — only reached when both of the above
|
|
146
|
+
# fail, i.e. the target would otherwise be skipped entirely):
|
|
147
|
+
# attribute the target's OWN INFOPLIST_FILE via the pbxproj's
|
|
148
|
+
# build-config graph. Works even when the plist's leading path
|
|
149
|
+
# component doesn't match the target name (e.g. brave-ios's
|
|
150
|
+
# Client -> "iOS/Supporting Files/Info.plist"), but must not
|
|
151
|
+
# override a working leading-component or GENERATE resolution
|
|
152
|
+
# (e.g. eigen's Artsy, cwa-app-ios's ENA), so it stays last.
|
|
153
|
+
plist="$(pm_target_infoplist "$pbx" "$app" 2>/dev/null)"
|
|
154
|
+
# A literal, unexpanded Xcode build variable (e.g. $(SRCROOT),
|
|
155
|
+
# $(PROJECT_DIR)) can never be found on disk — treat it as
|
|
156
|
+
# unusable rather than resolving to a broken path.
|
|
157
|
+
[[ "$plist" == *'$('* ]] && plist=""
|
|
158
|
+
if [[ -n "$plist" ]]; then
|
|
159
|
+
dir="$(dirname "$plist")"
|
|
160
|
+
else
|
|
161
|
+
continue
|
|
162
|
+
fi
|
|
163
|
+
fi
|
|
164
|
+
fi
|
|
165
|
+
n="$(cd "$root${projdir:+/$projdir}" 2>/dev/null && \
|
|
166
|
+
find "$dir" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')"
|
|
167
|
+
# Prefix the projdir so paths are ROOT-relative before comparing/storing.
|
|
168
|
+
cand_dir="${projdir:+$projdir/}$dir"
|
|
169
|
+
cand_plist=""
|
|
170
|
+
[[ -n "$plist" ]] && cand_plist="${projdir:+$projdir/}$plist"
|
|
171
|
+
if [[ "$projdir" =~ $PM_SAMPLE_PATH ]]; then
|
|
172
|
+
if (( n > alt_n )); then
|
|
173
|
+
alt_n=$n; alt="$cand_dir"; alt_plist="$cand_plist"
|
|
174
|
+
fi
|
|
175
|
+
else
|
|
176
|
+
if (( n > best_n )); then
|
|
177
|
+
best_n=$n; best="$cand_dir"; best_plist="$cand_plist"
|
|
178
|
+
fi
|
|
179
|
+
fi
|
|
180
|
+
done <<< "$apps"
|
|
181
|
+
done <<< "$(pm_find_pbxprojs "$root")"
|
|
182
|
+
[[ $found -eq 0 ]] && return 1
|
|
183
|
+
if [[ -n "$best" ]]; then
|
|
184
|
+
printf '%s\t%s\n' "$best" "$best_plist"
|
|
185
|
+
elif [[ -n "$alt" ]]; then
|
|
186
|
+
printf '%s\t%s\n' "$alt" "$alt_plist"
|
|
187
|
+
else
|
|
188
|
+
return 1
|
|
189
|
+
fi
|
|
190
|
+
}
|
|
@@ -12,6 +12,29 @@ 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
|
+
source "$(dirname "${BASH_SOURCE[0]}")/project-model.sh"
|
|
18
|
+
FINDINGS_TMP="$(mktemp)"; export FINDINGS_TMP
|
|
19
|
+
trap 'rm -f "$FINDINGS_TMP"' EXIT
|
|
20
|
+
FORMAT="text"
|
|
21
|
+
# The envelope `version` is the appstore-precheck TOOL's own version (from this
|
|
22
|
+
# skill's SKILL.md), never the scanned repo's — $ROOT above is the SCANNED app's
|
|
23
|
+
# git root, so reading its package.json here would leak the wrong version.
|
|
24
|
+
PRECHECK_VERSION="$(grep -m1 -E '^[[:space:]]*version:' "$(dirname "${BASH_SOURCE[0]}")/../SKILL.md" 2>/dev/null | sed -E 's/.*version:[[:space:]]*//; s/[[:space:]]*$//')"
|
|
25
|
+
[[ -z "$PRECHECK_VERSION" ]] && PRECHECK_VERSION="dev"
|
|
26
|
+
|
|
27
|
+
while [[ $# -gt 0 ]]; do
|
|
28
|
+
case "$1" in
|
|
29
|
+
--format)
|
|
30
|
+
if [[ $# -lt 2 ]]; then echo "scan.sh: --format needs a value (text|json)" >&2; exit 64; fi
|
|
31
|
+
FORMAT="$2"; shift 2 ;;
|
|
32
|
+
--format=*) FORMAT="${1#*=}"; shift ;;
|
|
33
|
+
*) shift ;;
|
|
34
|
+
esac
|
|
35
|
+
done
|
|
36
|
+
[[ "$FORMAT" == json || "$FORMAT" == text ]] || { echo "scan.sh: --format must be text|json" >&2; exit 64; }
|
|
37
|
+
|
|
15
38
|
CONFIG="${APPSTORE_PRECHECK_CONFIG:-.appstore-precheck.json}"
|
|
16
39
|
have_jq() { command -v jq >/dev/null 2>&1; }
|
|
17
40
|
|
|
@@ -32,9 +55,13 @@ cfg_bool() { # cfg_bool <json-path> — echoes "true"/"false"
|
|
|
32
55
|
echo "false"
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
58
|
+
_LAST_SUPPRESSED=0
|
|
59
|
+
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; }
|
|
60
|
+
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; }
|
|
61
|
+
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; }
|
|
62
|
+
|
|
63
|
+
# detail <text> — indented evidence under the previous finding; skipped when it was suppressed.
|
|
64
|
+
detail() { [[ "${_LAST_SUPPRESSED:-0}" == 1 ]] || printf '%s\n' "$1" | sed 's/^/ /'; }
|
|
38
65
|
|
|
39
66
|
# ===================================================================
|
|
40
67
|
# Auto-detection of the project layout
|
|
@@ -55,6 +82,17 @@ GREP_PRUNE=( --exclude-dir=.git --exclude-dir=Pods --exclude-dir=Carthage
|
|
|
55
82
|
--exclude-dir=.claude --exclude-dir=worktrees
|
|
56
83
|
--exclude-dir=node_modules --exclude-dir=vendor )
|
|
57
84
|
|
|
85
|
+
# Load .precheck-ignore path exclusions (Task 2 suppress.sh) and extend both
|
|
86
|
+
# prune sets so a suppressed path is skipped by detection AND grep passes.
|
|
87
|
+
load_precheck_ignore "$ROOT"
|
|
88
|
+
while IFS= read -r _g; do
|
|
89
|
+
[[ -z "$_g" ]] && continue
|
|
90
|
+
PRUNE+=( -not -path "*/$_g/*" -not -path "$_g/*" )
|
|
91
|
+
GREP_PRUNE+=( --exclude-dir="${_g##*/}" )
|
|
92
|
+
done <<PRUNE_GLOBS
|
|
93
|
+
$(precheck_prune_globs)
|
|
94
|
+
PRUNE_GLOBS
|
|
95
|
+
|
|
58
96
|
# Reads newline-separated paths on stdin, prints the one with the fewest path
|
|
59
97
|
# segments (the shallowest, i.e. the real project copy rather than a nested one).
|
|
60
98
|
pick_shallowest() { awk '{ n=gsub(/\//,"/"); print n"\t"$0 }' | sort -n | head -1 | cut -f2-; }
|
|
@@ -72,9 +110,21 @@ NONAPP_TARGET='(Watch|Extension|Widget|Intents|Clip|Notification|Share|Sticker|T
|
|
|
72
110
|
# can land on a Watch app, an app extension, or a framework instead of the real app.
|
|
73
111
|
# We score candidates by Swift-file count and deprioritize obvious non-app targets, so
|
|
74
112
|
# they only win when nothing app-like exists.
|
|
113
|
+
# Sets the globals IOS_DIR (and, when resolved via the project model, PM_INFO_PLIST)
|
|
114
|
+
# directly rather than echoing a result — this function MUST be called without
|
|
115
|
+
# command substitution (no `IOS_DIR="$(detect_ios_dir)"`) so its assignments run
|
|
116
|
+
# in the caller's shell instead of a subshell, where they would be discarded.
|
|
75
117
|
detect_ios_dir() {
|
|
76
118
|
local d; d=$(cfg '.iosSourceDir')
|
|
77
|
-
[[ -n "$d" ]] && {
|
|
119
|
+
[[ -n "$d" ]] && { IOS_DIR="$d"; return; }
|
|
120
|
+
# Authoritative: parse the Xcode project model when a .pbxproj exists.
|
|
121
|
+
local pm; pm="$(pm_resolve . 2>/dev/null)"
|
|
122
|
+
if [[ -n "$pm" ]]; then
|
|
123
|
+
PM_INFO_PLIST="$(printf '%s' "$pm" | cut -f2)"
|
|
124
|
+
IOS_DIR="$(printf '%s' "$pm" | cut -f1)"
|
|
125
|
+
return
|
|
126
|
+
fi
|
|
127
|
+
# Fallback: the original grep heuristic (unchanged).
|
|
78
128
|
local candidates plist entry
|
|
79
129
|
candidates=$(
|
|
80
130
|
find . "${PRUNE[@]}" -name Info.plist 2>/dev/null | while IFS= read -r plist; do dirname "$plist"; done
|
|
@@ -92,17 +142,18 @@ detect_ios_dir() {
|
|
|
92
142
|
(( n > best_n )) && { best_n=$n; best="$dir"; }
|
|
93
143
|
fi
|
|
94
144
|
done <<< "$candidates"
|
|
95
|
-
[[ -n "$best" ]]
|
|
96
|
-
echo "$alt"
|
|
145
|
+
if [[ -n "$best" ]]; then IOS_DIR="$best"; else IOS_DIR="$alt"; fi
|
|
97
146
|
}
|
|
98
147
|
|
|
99
|
-
|
|
148
|
+
PM_INFO_PLIST=""
|
|
149
|
+
IOS_DIR=""
|
|
150
|
+
detect_ios_dir
|
|
100
151
|
META_DIR="$(cfg '.metadataDir')"; [[ -z "$META_DIR" ]] && META_DIR="$(detect_first -type d -name metadata -path '*fastlane*')"
|
|
101
152
|
SCREEN_DIR="$(cfg '.screenshotsDir')"; [[ -z "$SCREEN_DIR" ]] && SCREEN_DIR="$(detect_first -type d -name screenshots -path '*fastlane*')"
|
|
102
153
|
XCSTRINGS="$(cfg '.xcstringsPath')"; [[ -z "$XCSTRINGS" ]] && XCSTRINGS="$(detect_first -name 'Localizable.xcstrings')"
|
|
103
154
|
[[ -z "$XCSTRINGS" ]] && XCSTRINGS="$(detect_first -name '*.xcstrings')"
|
|
104
155
|
PRIVACY_FILE="$(detect_first -name 'PrivacyInfo.xcprivacy')"
|
|
105
|
-
INFO_PLIST="${IOS_DIR%/}/Info.plist"
|
|
156
|
+
INFO_PLIST="${PM_INFO_PLIST:-${IOS_DIR%/}/Info.plist}"
|
|
106
157
|
REVIEW_PREP="$(cfg '.reviewPrepNotes')"
|
|
107
158
|
|
|
108
159
|
# Paywall / subscription views. A real app spreads its purchase UI across several files
|
|
@@ -150,6 +201,8 @@ SUB_KEY="$(cfg '.disclosureKeys.subscription' 'subscription_disclosure')"
|
|
|
150
201
|
TRIAL_KEY="$(cfg '.disclosureKeys.trial' 'subscription_trial_disclosure')"
|
|
151
202
|
CHECK_FAMILY="$(cfg_bool '.optionalChecks.familyControls')"
|
|
152
203
|
|
|
204
|
+
if [[ "$FORMAT" == json ]]; then exec 4>&1 1>/dev/null; fi
|
|
205
|
+
|
|
153
206
|
echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='${XCSTRINGS:-?}' locales=${#LOCALES[@]}"
|
|
154
207
|
|
|
155
208
|
# ===================================================================
|
|
@@ -159,14 +212,15 @@ echo "PASS: layout — ios='${IOS_DIR:-?}' metadata='${META_DIR:-?}' xcstrings='
|
|
|
159
212
|
# (v) "Account Sign-In" is a different rule — its account-deletion requirement is
|
|
160
213
|
# checked separately in §38.
|
|
161
214
|
# ===================================================================
|
|
215
|
+
set_rule "privacy-manifest-parity"
|
|
162
216
|
check_required_reason_api() {
|
|
163
217
|
local cat="$1" pattern="$2" hits declared
|
|
164
218
|
hits=$(grep -rEl "$pattern" "$IOS_DIR" --include="*.swift" 2>/dev/null | head -3)
|
|
165
219
|
declared=$(grep -c "NSPrivacyAccessedAPICategory${cat}" "$PRIVACY_FILE" 2>/dev/null)
|
|
166
220
|
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"
|
|
221
|
+
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
222
|
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)"
|
|
223
|
+
warn "5.1.1 PrivacyInfo — '$cat' declared but no code usage grepped (may be a false positive, verify manually)" "$PRIVACY_FILE"
|
|
170
224
|
elif [[ -n "$hits" && "${declared:-0}" -gt 0 ]]; then
|
|
171
225
|
pass "5.1.1 Required Reason API — '$cat' parity OK"
|
|
172
226
|
fi
|
|
@@ -174,7 +228,7 @@ check_required_reason_api() {
|
|
|
174
228
|
if [[ -z "$IOS_DIR" ]]; then
|
|
175
229
|
warn "layout — could not auto-detect iOS source dir; set .iosSourceDir in $CONFIG"
|
|
176
230
|
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)"
|
|
231
|
+
fail "5.1.1 Required Reason API — PrivacyInfo.xcprivacy not found (required since May 2024 for apps using Required Reason APIs)" "$INFO_PLIST"
|
|
178
232
|
else
|
|
179
233
|
check_required_reason_api "UserDefaults" 'UserDefaults|@AppStorage'
|
|
180
234
|
check_required_reason_api "FileTimestamp" 'attributesOfItem|creationDate|modificationDate|\.fileCreationDate|\.fileModificationDate'
|
|
@@ -186,26 +240,52 @@ fi
|
|
|
186
240
|
# ===================================================================
|
|
187
241
|
# §2 — 5.1.1 NSUsageDescription cross-check (Info.plist)
|
|
188
242
|
# ===================================================================
|
|
243
|
+
set_rule "usage-description-crosscheck"
|
|
189
244
|
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)"
|
|
245
|
+
[[ -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
246
|
else
|
|
192
247
|
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)"
|
|
248
|
+
[[ -n "$line" ]] && fail "5.1.1 Purpose String — $line (empty usage description is rejected by App Review)" "$INFO_PLIST"
|
|
194
249
|
done
|
|
195
250
|
for fw in \
|
|
196
251
|
"FamilyControls|ManagedSettings|DeviceActivity:NSFamilyControlsUsageDescription" \
|
|
197
252
|
"CoreLocation:NSLocationWhenInUseUsageDescription" \
|
|
198
|
-
"AVFoundation:NSCameraUsageDescription|NSMicrophoneUsageDescription" \
|
|
199
|
-
"Photos:NSPhotoLibraryUsageDescription" \
|
|
200
253
|
"Contacts:NSContactsUsageDescription" \
|
|
201
254
|
"HealthKit:NSHealthShareUsageDescription"; do
|
|
202
255
|
framework="${fw%%:*}"; needed_key="${fw##*:}"
|
|
203
256
|
if grep -rqE "import ($framework)|($framework)\." "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
204
257
|
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%%|*}'"
|
|
258
|
+
fail "5.1.1 framework '${framework%%|*}' imported but Info.plist is missing '${needed_key%%|*}'" "$INFO_PLIST"
|
|
206
259
|
fi
|
|
207
260
|
fi
|
|
208
261
|
done
|
|
262
|
+
# AVFoundation and Photos are gated on real capture/read APIs, not bare
|
|
263
|
+
# imports: an import only signals framework linkage, not camera/mic/library
|
|
264
|
+
# access (e.g. AVFoundation is commonly imported for playback-only use via
|
|
265
|
+
# AVAudioPlayer/AVPlayer, and Photos/PhotosUI's PhotosPicker/PHPickerViewController
|
|
266
|
+
# run out-of-process and need no Info.plist key at all).
|
|
267
|
+
# Camera: purpose string required only when a capture API is actually used.
|
|
268
|
+
if grep -rqE 'AVCaptureDevice|AVCaptureSession|UIImagePickerController' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
269
|
+
grep -qE 'NSCameraUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
270
|
+
fail "5.1.1 camera capture API used but Info.plist is missing 'NSCameraUsageDescription'" "$INFO_PLIST"
|
|
271
|
+
fi
|
|
272
|
+
# Microphone: required only for recording/capture, not playback. Matched
|
|
273
|
+
# separately from the camera check above: a bare AVCaptureDevice only
|
|
274
|
+
# implies microphone use when it is audio-typed (`.audio` device/media
|
|
275
|
+
# type), not for a video-only capture device.
|
|
276
|
+
if grep -rqE 'AVAudioRecorder|installTap\(|AVAudioEngine\(|AVAudioSession[^;]*\.(playAndRecord|record)|AVCaptureDevice[^;)]*\.audio|for:[[:space:]]*\.audio' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
277
|
+
grep -qE 'NSMicrophoneUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
278
|
+
fail "5.1.1 microphone/recording API used but Info.plist is missing 'NSMicrophoneUsageDescription'" "$INFO_PLIST"
|
|
279
|
+
fi
|
|
280
|
+
# Photo library READ: PhotosPicker/PHPicker need no key; only true read/fetch APIs do.
|
|
281
|
+
if grep -rqE 'PHAsset\b|PHFetchResult|PHImageManager|fetchAssets|PHAssetCollection' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
282
|
+
grep -qE 'NSPhotoLibraryUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
283
|
+
fail "5.1.1 Photos read API used but Info.plist is missing 'NSPhotoLibraryUsageDescription'" "$INFO_PLIST"
|
|
284
|
+
elif grep -rqE 'PHAssetCreationRequest|UIImageWriteToSavedPhotosAlbum|performChanges' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
285
|
+
# Add-only save: covered by the add-only key.
|
|
286
|
+
grep -qE 'NSPhotoLibraryAddUsageDescription' "$INFO_PLIST" 2>/dev/null || \
|
|
287
|
+
fail "5.1.1 Photos add-only API used but Info.plist is missing 'NSPhotoLibraryAddUsageDescription'" "$INFO_PLIST"
|
|
288
|
+
fi
|
|
209
289
|
fi
|
|
210
290
|
|
|
211
291
|
# ===================================================================
|
|
@@ -214,13 +294,14 @@ fi
|
|
|
214
294
|
# Ad / attribution / IDFA SDK signal, shared with §16. The reverse of §3: §3 fires
|
|
215
295
|
# when the ATT framework IS imported; §16 fires when a tracking SDK is present but
|
|
216
296
|
# the ATT prompt is NOT. Computed once here so the two checks never contradict.
|
|
297
|
+
set_rule "att-usage"
|
|
217
298
|
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
299
|
att_used=$(grep -rlE 'AppTrackingTransparency|ATTrackingManager' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
219
300
|
if [[ -n "$att_used" ]]; then
|
|
220
301
|
if grep -q "NSUserTrackingUsageDescription" "$INFO_PLIST" 2>/dev/null; then
|
|
221
302
|
pass "5.1.2 ATT — used + NSUserTrackingUsageDescription present"
|
|
222
303
|
else
|
|
223
|
-
fail "5.1.2 ATT framework imported but NSUserTrackingUsageDescription missing in Info.plist ($att_used)"
|
|
304
|
+
fail "5.1.2 ATT framework imported but NSUserTrackingUsageDescription missing in Info.plist ($att_used)" "$INFO_PLIST"
|
|
224
305
|
fi
|
|
225
306
|
elif [[ -z "$tracking_sdk" ]]; then
|
|
226
307
|
pass "5.1.2 ATT — not used (no tracking)"
|
|
@@ -231,12 +312,13 @@ fi
|
|
|
231
312
|
# ===================================================================
|
|
232
313
|
# §4 — 2.3.10 Other-platform / competitor mentions in metadata
|
|
233
314
|
# ===================================================================
|
|
315
|
+
set_rule "competitor-mentions"
|
|
234
316
|
if [[ -d "$META_DIR" ]]; then
|
|
235
317
|
banned_re='Android|Google[[:space:]]?Play|Play[[:space:]]?Store|Windows[[:space:]]?Phone|Samsung Galaxy|Huawei AppGallery|F-Droid'
|
|
236
318
|
hits=$(grep -rEnI "$banned_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -30)
|
|
237
319
|
if [[ -n "$hits" ]]; then
|
|
238
320
|
fail "2.3.10 Other-platform mention — banned reference in metadata:"
|
|
239
|
-
|
|
321
|
+
detail "$hits"
|
|
240
322
|
else
|
|
241
323
|
pass "2.3.10 Other-platform mentions — metadata clean"
|
|
242
324
|
fi
|
|
@@ -245,6 +327,7 @@ fi
|
|
|
245
327
|
# ===================================================================
|
|
246
328
|
# §5 — 2.3.1 Metadata character limits (Unicode codepoints, like ASC)
|
|
247
329
|
# ===================================================================
|
|
330
|
+
set_rule "metadata-char-limits"
|
|
248
331
|
check_len() {
|
|
249
332
|
local file="$1" limit="$2" label="$3" len
|
|
250
333
|
[[ -f "$file" ]] || return
|
|
@@ -254,7 +337,7 @@ check_len() {
|
|
|
254
337
|
len=$(wc -m < "$file" | tr -d ' ')
|
|
255
338
|
fi
|
|
256
339
|
[[ -z "$len" ]] && return
|
|
257
|
-
(( len > limit )) && fail "2.3.1 $label — $file ${len} chars (limit ${limit})"
|
|
340
|
+
(( len > limit )) && fail "2.3.1 $label — $file ${len} chars (limit ${limit})" "$file"
|
|
258
341
|
}
|
|
259
342
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
260
343
|
d="$META_DIR/$loc"; [[ -d "$d" ]] || continue
|
|
@@ -268,6 +351,7 @@ done
|
|
|
268
351
|
# ===================================================================
|
|
269
352
|
# §6 — 2.3.7 Localized metadata parity across all detected locales
|
|
270
353
|
# ===================================================================
|
|
354
|
+
set_rule "locale-metadata-parity"
|
|
271
355
|
if (( ${#LOCALES[@]} > 0 )); then
|
|
272
356
|
expected_files=(name.txt subtitle.txt description.txt keywords.txt)
|
|
273
357
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
@@ -294,6 +378,7 @@ fi
|
|
|
294
378
|
# ===================================================================
|
|
295
379
|
# §7 — 2.3.3 Screenshots per locale
|
|
296
380
|
# ===================================================================
|
|
381
|
+
set_rule "screenshots-per-locale"
|
|
297
382
|
if [[ -n "$SCREEN_DIR" && -d "$SCREEN_DIR" ]]; then
|
|
298
383
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
299
384
|
d="$SCREEN_DIR/$loc"
|
|
@@ -307,20 +392,24 @@ if [[ -n "$SCREEN_DIR" && -d "$SCREEN_DIR" ]]; then
|
|
|
307
392
|
done
|
|
308
393
|
pass "2.3.3 Screenshots — checked ${#LOCALES[@]} locales under $SCREEN_DIR"
|
|
309
394
|
else
|
|
310
|
-
|
|
395
|
+
pass "2.3.3 Screenshots — no in-repo screenshots dir; assumed managed in App Store Connect (set .screenshotsDir to check in-repo)"
|
|
311
396
|
fi
|
|
312
397
|
|
|
313
398
|
# ===================================================================
|
|
314
399
|
# In-app purchase gate — only run 3.1.2 checks if IAP signals exist
|
|
315
400
|
# ===================================================================
|
|
316
401
|
iap_detected=""
|
|
317
|
-
grep -rqE '
|
|
402
|
+
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
403
|
[[ -n "$SUB_VIEW" ]] && iap_detected=1
|
|
319
404
|
|
|
320
405
|
if [[ -z "$iap_detected" ]]; then
|
|
406
|
+
# Not one of the 41 catalog sections — clear the rule_id so this PASS doesn't
|
|
407
|
+
# inherit §7's "screenshots-per-locale" slug in the JSON output.
|
|
408
|
+
set_rule ""
|
|
321
409
|
pass "3.1.2 IAP — no in-app purchase / subscription signals detected, skipping paywall checks"
|
|
322
410
|
else
|
|
323
411
|
# ---- §8 3.1.2 Trial disclosure -------------------------------------------------
|
|
412
|
+
set_rule "trial-disclosure"
|
|
324
413
|
trial_re='free[[:space:]]?trial|trial[[:space:]]?period|ücretsiz[[:space:]]?dene|kostenlos[[:space:]]?test|essai[[:space:]]?gratuit|prueba[[:space:]]?grat|無料.*トライアル|무료[[:space:]]?체험'
|
|
325
414
|
if [[ -n "$XCSTRINGS" ]] && grep -EqI "$trial_re" "$XCSTRINGS" 2>/dev/null; then
|
|
326
415
|
if grep -q "$TRIAL_KEY" "$XCSTRINGS" 2>/dev/null; then
|
|
@@ -333,6 +422,7 @@ else
|
|
|
333
422
|
fi
|
|
334
423
|
|
|
335
424
|
# ---- §9 3.1.2 Auto-renew subscription disclosure -------------------------------
|
|
425
|
+
set_rule "autorenew-disclosure"
|
|
336
426
|
if [[ -n "$XCSTRINGS" ]]; then
|
|
337
427
|
if have_jq && jq -e ".strings | has(\"$SUB_KEY\")" "$XCSTRINGS" >/dev/null 2>&1; then
|
|
338
428
|
pass "3.1.2 subscription disclosure key '$SUB_KEY' present"
|
|
@@ -353,6 +443,7 @@ else
|
|
|
353
443
|
# Grep across the whole paywall cluster: a link present in ANY paywall view satisfies
|
|
354
444
|
# the requirement; only its absence from ALL of them is a FAIL. SUB_VIEW names the
|
|
355
445
|
# representative file in messages.
|
|
446
|
+
set_rule "subscription-links-restore"
|
|
356
447
|
if (( ${#PAYWALL_FILES[@]} > 0 )); then
|
|
357
448
|
if grep -qE 'restore|restorePurchases' "${PAYWALL_FILES[@]}"; then
|
|
358
449
|
pass "3.1.2 Restore Purchases — present in $(basename "$SUB_VIEW")"
|
|
@@ -377,11 +468,15 @@ fi
|
|
|
377
468
|
# ===================================================================
|
|
378
469
|
# §11 — 2.5.1 Private / banned API
|
|
379
470
|
# ===================================================================
|
|
471
|
+
set_rule "private-api"
|
|
380
472
|
banned_api='UIWebView|setSelectionIndicatorImage|_UIBackdropView|NSURLConnection[^a-zA-Z]|UIAlertView[^Q]|UIActionSheet[^Q]'
|
|
381
473
|
banned_hits=$(grep -rEnI "$banned_api" "$IOS_DIR" --include="*.swift" --include="*.m" --include="*.h" 2>/dev/null | head -5)
|
|
382
474
|
if [[ -n "$banned_hits" ]]; then
|
|
383
|
-
|
|
384
|
-
|
|
475
|
+
pa_first="$(printf '%s\n' "$banned_hits" | head -1)" # "path:line:match"
|
|
476
|
+
pa_file="${pa_first%%:*}"
|
|
477
|
+
pa_rest="${pa_first#*:}"; pa_line="${pa_rest%%:*}"
|
|
478
|
+
fail "2.5.1 Private/Deprecated API:" "$pa_file" "$pa_line"
|
|
479
|
+
detail "$banned_hits"
|
|
385
480
|
else
|
|
386
481
|
pass "2.5.1 Private API — clean"
|
|
387
482
|
fi
|
|
@@ -389,7 +484,8 @@ fi
|
|
|
389
484
|
# ===================================================================
|
|
390
485
|
# §12 — 4.2 Minimum functionality — navigation hubs
|
|
391
486
|
# ===================================================================
|
|
392
|
-
|
|
487
|
+
set_rule "min-functionality-nav"
|
|
488
|
+
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
489
|
if (( tab_count < 1 )); then
|
|
394
490
|
warn "4.2 Minimum functionality — no TabView/NavigationStack found (heuristic, may be a false positive)"
|
|
395
491
|
else
|
|
@@ -399,6 +495,7 @@ fi
|
|
|
399
495
|
# ===================================================================
|
|
400
496
|
# §13 — 5.1.5 Screen Time / sensitive-API justification (optional, opt-in)
|
|
401
497
|
# ===================================================================
|
|
498
|
+
set_rule "screentime-justification"
|
|
402
499
|
if [[ "$CHECK_FAMILY" == "true" ]] && grep -q "NSFamilyControlsUsageDescription" "$INFO_PLIST" 2>/dev/null; then
|
|
403
500
|
if [[ -n "$REVIEW_PREP" && -f "$REVIEW_PREP" ]] && grep -qiE 'family|screen[[:space:]]?time' "$REVIEW_PREP" 2>/dev/null; then
|
|
404
501
|
pass "5.1.5 Screen Time API — reviewer-prep justification note present"
|
|
@@ -410,6 +507,7 @@ fi
|
|
|
410
507
|
# ===================================================================
|
|
411
508
|
# §14 — 4.8 Sign in with Apple parity (only when a third-party social login is used)
|
|
412
509
|
# ===================================================================
|
|
510
|
+
set_rule "siwa-parity"
|
|
413
511
|
if [[ -n "$IOS_DIR" ]]; then
|
|
414
512
|
social_login=$(grep -rlE 'GIDSignIn|import GoogleSignIn|FBSDKLoginKit|FBSDKLoginManager|FacebookLogin|import Auth0|LineSDK|VKSdkAuthorization' "$IOS_DIR" --include='*.swift' 2>/dev/null | head -1)
|
|
415
513
|
if [[ -n "$social_login" ]]; then
|
|
@@ -424,6 +522,7 @@ fi
|
|
|
424
522
|
# ===================================================================
|
|
425
523
|
# §15 — 3.1.1(a) External purchase link (StoreKit External Purchase)
|
|
426
524
|
# ===================================================================
|
|
525
|
+
set_rule "external-purchase-link"
|
|
427
526
|
ext_purchase=""
|
|
428
527
|
grep -rqE 'ExternalPurchase|ExternalPurchaseLink|ExternalPurchaseCustomLink' "${IOS_DIR:-.}" --include='*.swift' 2>/dev/null && ext_purchase=1
|
|
429
528
|
grep -rqE 'external-purchase' "${IOS_DIR:-.}" --include='*.entitlements' 2>/dev/null && ext_purchase=1
|
|
@@ -437,6 +536,7 @@ fi
|
|
|
437
536
|
# §3 catches "ATT framework imported but no usage string". This catches the more
|
|
438
537
|
# common rejection: shipping an ad / attribution SDK (or touching the IDFA) without
|
|
439
538
|
# ever presenting the ATT prompt. `tracking_sdk` and `att_used` are computed in §3.
|
|
539
|
+
set_rule "tracking-sdk-no-att"
|
|
440
540
|
if [[ -n "$tracking_sdk" ]]; then
|
|
441
541
|
att_present=""
|
|
442
542
|
[[ -n "$att_used" ]] && att_present=1
|
|
@@ -455,11 +555,12 @@ fi
|
|
|
455
555
|
# submission. Setting it (true/false) removes that friction. Only checked when an
|
|
456
556
|
# Info.plist exists; a modern app may auto-generate it, in which case the key lives
|
|
457
557
|
# in build settings, so we stay silent rather than nag.
|
|
558
|
+
set_rule "export-compliance"
|
|
458
559
|
if [[ -f "$INFO_PLIST" ]]; then
|
|
459
560
|
if grep -q "ITSAppUsesNonExemptEncryption" "$INFO_PLIST" 2>/dev/null; then
|
|
460
561
|
pass "export-compliance — ITSAppUsesNonExemptEncryption set in Info.plist"
|
|
461
562
|
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"
|
|
563
|
+
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
564
|
fi
|
|
464
565
|
fi
|
|
465
566
|
|
|
@@ -470,6 +571,7 @@ fi
|
|
|
470
571
|
# support_url.txt / privacy_url.txt / marketing_url.txt. Apple requires a working
|
|
471
572
|
# support URL, and a privacy policy URL for apps with accounts or IAP. Flag when
|
|
472
573
|
# they are absent across every locale, or contain an obvious placeholder.
|
|
574
|
+
set_rule "support-privacy-url"
|
|
473
575
|
if [[ -d "$META_DIR" ]]; then
|
|
474
576
|
support_found="" privacy_found=""
|
|
475
577
|
for loc in "${LOCALES[@]+"${LOCALES[@]}"}"; do
|
|
@@ -482,8 +584,9 @@ if [[ -d "$META_DIR" ]]; then
|
|
|
482
584
|
fi
|
|
483
585
|
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
586
|
if [[ -n "$url_ph" ]]; then
|
|
485
|
-
|
|
486
|
-
|
|
587
|
+
url_ph_file="$(printf '%s\n' "$url_ph" | head -1 | cut -d: -f1)"
|
|
588
|
+
warn "2.3 Metadata URL — placeholder URL in fastlane metadata (replace before submitting):" "$url_ph_file"
|
|
589
|
+
detail "$url_ph"
|
|
487
590
|
fi
|
|
488
591
|
fi
|
|
489
592
|
|
|
@@ -495,7 +598,8 @@ fi
|
|
|
495
598
|
# domains, the privacy manifest and the App Privacy nutrition labels are probably
|
|
496
599
|
# incomplete. Soft "verify" wording (a crash-only Sentry may genuinely collect
|
|
497
600
|
# nothing), so WARN, never FAIL.
|
|
498
|
-
|
|
601
|
+
set_rule "analytics-privacyinfo-mismatch"
|
|
602
|
+
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
603
|
if [[ -n "$analytics_sdk" ]]; then
|
|
500
604
|
declared_data=""
|
|
501
605
|
if [[ -n "$PRIVACY_FILE" && -f "$PRIVACY_FILE" ]]; then
|
|
@@ -507,7 +611,7 @@ if [[ -n "$analytics_sdk" ]]; then
|
|
|
507
611
|
if [[ -n "$declared_data" ]]; then
|
|
508
612
|
pass "5.1.1 Privacy manifest — analytics SDK present and PrivacyInfo declares collected data"
|
|
509
613
|
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"
|
|
614
|
+
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
615
|
fi
|
|
512
616
|
fi
|
|
513
617
|
|
|
@@ -518,12 +622,14 @@ fi
|
|
|
518
622
|
# get rejected under 2.1 and look unfinished. Conservative, specific patterns to
|
|
519
623
|
# avoid flagging legitimate words. Overlaps the Phase 2 precheck "No placeholder
|
|
520
624
|
# text" rule, but this one is local and runs before any network call.
|
|
625
|
+
set_rule "placeholder-metadata"
|
|
521
626
|
if [[ -d "$META_DIR" ]]; then
|
|
522
627
|
ph_re='lorem ipsum|Lorem ipsum|\bTODO\b|\bFIXME\b|example\.com|placeholder|insert .* here|\bchangeme\b'
|
|
523
628
|
ph_hits=$(grep -rEnI "$ph_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -20)
|
|
524
629
|
if [[ -n "$ph_hits" ]]; then
|
|
525
|
-
|
|
526
|
-
|
|
630
|
+
ph_file="$(printf '%s\n' "$ph_hits" | head -1 | cut -d: -f1)"
|
|
631
|
+
warn "2.1 Metadata content — placeholder/dummy text in store metadata (looks unfinished; rejected under 2.1):" "$ph_file"
|
|
632
|
+
detail "$ph_hits"
|
|
527
633
|
fi
|
|
528
634
|
fi
|
|
529
635
|
|
|
@@ -534,6 +640,7 @@ fi
|
|
|
534
640
|
# third-party payment SDK (Stripe, Braintree, PayPal, …) is legitimate for
|
|
535
641
|
# PHYSICAL goods/services but a frequent rejection when used to sell digital
|
|
536
642
|
# content. We can't tell digital from physical statically, so this is advisory.
|
|
643
|
+
set_rule "thirdparty-payment-sdk"
|
|
537
644
|
if [[ -n "$IOS_DIR" ]]; then
|
|
538
645
|
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
646
|
if [[ -n "$payment_sdk" ]]; then
|
|
@@ -547,6 +654,7 @@ fi
|
|
|
547
654
|
# Apps with UGC must provide: a content filter, a report mechanism, the ability
|
|
548
655
|
# to block abusive users, and published contact info. We detect a UGC signal and
|
|
549
656
|
# warn when no report/block/moderation affordance is found anywhere in the source.
|
|
657
|
+
set_rule "ugc-no-moderation"
|
|
550
658
|
if [[ -n "$IOS_DIR" ]]; then
|
|
551
659
|
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
660
|
if [[ -n "$ugc_signal" ]]; then
|
|
@@ -563,12 +671,13 @@ fi
|
|
|
563
671
|
# ===================================================================
|
|
564
672
|
# NSAllowsArbitraryLoads=true turns off ATS for the whole app, weakening
|
|
565
673
|
# data-in-transit security and inviting a 1.6 / data-security question at review.
|
|
674
|
+
set_rule "ats-arbitrary-loads"
|
|
566
675
|
if [[ -f "$INFO_PLIST" ]]; then
|
|
567
676
|
# Anchor to the exact key: `NSAllowsArbitraryLoads</key>` (followed by `<`), so the
|
|
568
677
|
# narrower scoped exceptions NSAllowsArbitraryLoadsInWebContent / ...ForMedia — which
|
|
569
678
|
# do NOT disable ATS app-wide and are the recommended alternative — don't false-fire.
|
|
570
679
|
if awk '/NSAllowsArbitraryLoads</{getline; if ($0 ~ /<true/) print "ON"}' "$INFO_PLIST" 2>/dev/null | grep -q ON; then
|
|
571
|
-
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)"
|
|
680
|
+
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"
|
|
572
681
|
fi
|
|
573
682
|
fi
|
|
574
683
|
|
|
@@ -580,6 +689,7 @@ fi
|
|
|
580
689
|
# Pay recurring API (PKRecurringPaymentRequest) so it does NOT conflate StoreKit
|
|
581
690
|
# auto-renew copy or a one-time PassKit payment with recurring Apple Pay. We don't
|
|
582
691
|
# try to detect the disclosure text (too noisy) — we flag it for manual verify.
|
|
692
|
+
set_rule "applepay-recurring-disclosure"
|
|
583
693
|
if [[ -n "$IOS_DIR" ]] && grep -rqE 'PKRecurringPaymentRequest' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
584
694
|
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)"
|
|
585
695
|
fi
|
|
@@ -589,6 +699,7 @@ fi
|
|
|
589
699
|
# ===================================================================
|
|
590
700
|
# Apple disallows custom review prompts and direct write-review links — apps must
|
|
591
701
|
# use the system SKStoreReviewController / requestReview API.
|
|
702
|
+
set_rule "custom-review-prompt"
|
|
592
703
|
if [[ -n "$IOS_DIR" ]]; then
|
|
593
704
|
review_link=$(grep -rlE 'write-review|action=write-review|itms-apps[^"]*review' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
594
705
|
if [[ -n "$review_link" ]]; then
|
|
@@ -605,12 +716,14 @@ fi
|
|
|
605
716
|
# ===================================================================
|
|
606
717
|
# Marketing the app for things iOS apps can't actually do (virus/malware
|
|
607
718
|
# scanners, fake speed boosters) is a 2.3.1 removal vector.
|
|
719
|
+
set_rule "misleading-marketing"
|
|
608
720
|
if [[ -d "$META_DIR" ]]; then
|
|
609
721
|
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)'
|
|
610
722
|
mislead_hits=$(grep -rEniI "$mislead_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
611
723
|
if [[ -n "$mislead_hits" ]]; then
|
|
612
|
-
|
|
613
|
-
|
|
724
|
+
mislead_file="$(printf '%s\n' "$mislead_hits" | head -1 | cut -d: -f1)"
|
|
725
|
+
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"
|
|
726
|
+
detail "$mislead_hits"
|
|
614
727
|
fi
|
|
615
728
|
fi
|
|
616
729
|
|
|
@@ -619,6 +732,7 @@ fi
|
|
|
619
732
|
# ===================================================================
|
|
620
733
|
# Terms implying a child audience in name/subtitle/keywords/description are
|
|
621
734
|
# reserved for the Kids Category (2.3.8 / 5.1.4).
|
|
735
|
+
set_rule "kids-wording"
|
|
622
736
|
if [[ -d "$META_DIR" ]]; then
|
|
623
737
|
kids_re='for kids|for children|for your (kid|child)|kids[[:space:]]?app|für kinder|para niños|pour enfants'
|
|
624
738
|
kids_hits=$(grep -rEniI "$kids_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
@@ -626,8 +740,9 @@ if [[ -d "$META_DIR" ]]; then
|
|
|
626
740
|
# more specific Kids finding for the same wording signal — don't double-count it here
|
|
627
741
|
# (one root signal must not cost two WARNs against the 5-WARN YELLOW threshold).
|
|
628
742
|
if [[ -n "$kids_hits" && -z "${tracking_sdk:-}" && -z "${analytics_sdk:-}" ]]; then
|
|
629
|
-
|
|
630
|
-
|
|
743
|
+
kids_file="$(printf '%s\n' "$kids_hits" | head -1 | cut -d: -f1)"
|
|
744
|
+
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"
|
|
745
|
+
detail "$kids_hits"
|
|
631
746
|
fi
|
|
632
747
|
fi
|
|
633
748
|
|
|
@@ -636,6 +751,7 @@ fi
|
|
|
636
751
|
# ===================================================================
|
|
637
752
|
# Keyboards must stay functional without full network access / "full access",
|
|
638
753
|
# and may only collect data to enhance the keyboard.
|
|
754
|
+
set_rule "keyboard-full-access"
|
|
639
755
|
kb_plist=$(grep -rlE 'com\.apple\.keyboard-service' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
640
756
|
if [[ -n "$kb_plist" ]]; then
|
|
641
757
|
if grep -A1 'RequestsOpenAccess' "$kb_plist" 2>/dev/null | grep -q '<true'; then
|
|
@@ -650,6 +766,7 @@ fi
|
|
|
650
766
|
# ===================================================================
|
|
651
767
|
# Personal health information must not be stored in iCloud, and HealthKit data
|
|
652
768
|
# may not be used for advertising/marketing.
|
|
769
|
+
set_rule "health-icloud-sync"
|
|
653
770
|
if [[ -n "$IOS_DIR" ]] && grep -rqE 'import HealthKit|HKHealthStore' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
654
771
|
if grep -rqE 'import CloudKit|CKRecord|CKContainer|NSUbiquitousKeyValueStore|NSUbiquitousContainer' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
655
772
|
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."
|
|
@@ -663,6 +780,7 @@ fi
|
|
|
663
780
|
# ===================================================================
|
|
664
781
|
# VPN apps must be offered by an organization account, declare data collection
|
|
665
782
|
# on-screen before use, and may not sell/share data.
|
|
783
|
+
set_rule "vpn-networkextension"
|
|
666
784
|
if [[ -n "$IOS_DIR" ]]; then
|
|
667
785
|
vpn_use=$(grep -rlE 'NEVPNManager|NETunnelProviderManager|NEPacketTunnelProvider|NEVPNProtocol' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
668
786
|
if [[ -n "$vpn_use" ]]; then
|
|
@@ -677,6 +795,7 @@ fi
|
|
|
677
795
|
# or notes). We fire only on a credential-login signal (a password field or a
|
|
678
796
|
# Login/SignIn view), then look for demo creds in fastlane review_information or
|
|
679
797
|
# the reviewer-prep notes. Social-only logins are not gated here (too noisy).
|
|
798
|
+
set_rule "demo-account"
|
|
680
799
|
if [[ -n "$IOS_DIR" ]]; then
|
|
681
800
|
auth_signal=$(grep -rlE 'SecureField' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
682
801
|
[[ -z "$auth_signal" ]] && auth_signal=$(find "$IOS_DIR" "${PRUNE[@]}" \( -name '*Login*View*.swift' -o -name '*SignIn*View*.swift' \) 2>/dev/null | head -1)
|
|
@@ -703,6 +822,7 @@ fi
|
|
|
703
822
|
# Apps must be self-contained; native hot-patching frameworks (JSPatch, Rollout,
|
|
704
823
|
# DynamicCocoa) download code that changes features and are a removal vector.
|
|
705
824
|
# JS-bundle OTA for React Native (CodePush) is allowed, so we do NOT flag it.
|
|
825
|
+
set_rule "executable-code-download"
|
|
706
826
|
if [[ -n "$IOS_DIR" ]]; then
|
|
707
827
|
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)
|
|
708
828
|
if [[ -n "$hotcode" ]]; then
|
|
@@ -716,6 +836,7 @@ fi
|
|
|
716
836
|
# Declare only the UIBackgroundModes the app actually uses; a mode declared with
|
|
717
837
|
# no matching API is a frequent rejection. We parse the array and check each
|
|
718
838
|
# declared mode against its framework/API in Swift.
|
|
839
|
+
set_rule "background-modes-unused"
|
|
719
840
|
if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
|
|
720
841
|
modes=$(awk '/<key>UIBackgroundModes<\/key>/{f=1;next} f&&/<\/array>/{f=0} f&&/<string>/{gsub(/.*<string>|<\/string>.*/,""); print}' "$INFO_PLIST" 2>/dev/null)
|
|
721
842
|
if [[ -n "$modes" ]]; then
|
|
@@ -733,7 +854,7 @@ if [[ -f "$INFO_PLIST" && -n "$IOS_DIR" ]]; then
|
|
|
733
854
|
esac
|
|
734
855
|
done <<< "$modes"
|
|
735
856
|
if [[ -n "$unused" ]]; then
|
|
736
|
-
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)"
|
|
857
|
+
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"
|
|
737
858
|
else
|
|
738
859
|
pass "2.5.4 Background modes — declared modes have matching API usage"
|
|
739
860
|
fi
|
|
@@ -743,6 +864,7 @@ fi
|
|
|
743
864
|
# ===================================================================
|
|
744
865
|
# §34 — 3.1.5(a) Cryptocurrency wallet / exchange / mining
|
|
745
866
|
# ===================================================================
|
|
867
|
+
set_rule "crypto-wallet-mining"
|
|
746
868
|
if [[ -n "$IOS_DIR" ]]; then
|
|
747
869
|
crypto_sdk=$(grep -rlE 'import Web3|web3swift|Web3Swift|WalletConnect|TrustWalletCore|CoinbaseWalletSDK|SolanaSwift|CryptoMining|coinhive|MoneroMiner' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
748
870
|
if [[ -n "$crypto_sdk" ]]; then
|
|
@@ -756,6 +878,7 @@ fi
|
|
|
756
878
|
# A thin WKWebView wrapper around a website is rejected under minimum
|
|
757
879
|
# functionality. Heuristic: WKWebView present in a project with very few Swift
|
|
758
880
|
# files. WARN (verify) — this is the most false-positive-prone of the batch.
|
|
881
|
+
set_rule "webview-wrapper"
|
|
759
882
|
if [[ -n "$IOS_DIR" ]]; then
|
|
760
883
|
if grep -rqE 'WKWebView' "$IOS_DIR" --include="*.swift" 2>/dev/null; then
|
|
761
884
|
swift_n=$(find "$IOS_DIR" "${PRUNE[@]}" -name '*.swift' 2>/dev/null | wc -l | tr -d ' ')
|
|
@@ -768,6 +891,7 @@ fi
|
|
|
768
891
|
# ===================================================================
|
|
769
892
|
# §36 — 4.2.7 Remote desktop / host-mirroring
|
|
770
893
|
# ===================================================================
|
|
894
|
+
set_rule "remote-desktop"
|
|
771
895
|
if [[ -n "$IOS_DIR" ]]; then
|
|
772
896
|
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)
|
|
773
897
|
if [[ -n "$remote_desktop" ]]; then
|
|
@@ -778,6 +902,7 @@ fi
|
|
|
778
902
|
# ===================================================================
|
|
779
903
|
# §37 — 4.4.2 Safari extension / content blocker
|
|
780
904
|
# ===================================================================
|
|
905
|
+
set_rule "safari-extension"
|
|
781
906
|
safari_ext=$(grep -rlE 'com\.apple\.Safari\.(content-blocker|web-extension|extension)' --include='Info.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
782
907
|
if [[ -n "$safari_ext" ]]; then
|
|
783
908
|
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."
|
|
@@ -789,6 +914,7 @@ fi
|
|
|
789
914
|
# Apple 5.1.1(v): apps that support account creation must also let users delete
|
|
790
915
|
# their account from within the app. Detect account-creation signals, then look
|
|
791
916
|
# for an in-app deletion path. Deletion via a web page is missed → WARN, not FAIL.
|
|
917
|
+
set_rule "account-no-delete"
|
|
792
918
|
if [[ -n "$IOS_DIR" ]]; then
|
|
793
919
|
signup=$(grep -rlE 'createUser|signUp|signup|createAccount|registerUser|registerNewUser|Auth\.auth\(\)\.createUser' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
794
920
|
if [[ -n "$signup" ]]; then
|
|
@@ -807,6 +933,7 @@ fi
|
|
|
807
933
|
# must include a parental gate. We fire when the metadata targets a child
|
|
808
934
|
# audience AND an ad/analytics SDK is linked. tracking_sdk/analytics_sdk come
|
|
809
935
|
# from §3/§19.
|
|
936
|
+
set_rule "kids-ads-analytics"
|
|
810
937
|
if [[ -d "$META_DIR" && -n "$IOS_DIR" ]]; then
|
|
811
938
|
if grep -rqEiI 'for kids|for children|kids[[:space:]]?app|für kinder|para niños|pour enfants' "$META_DIR" 2>/dev/null; then
|
|
812
939
|
if [[ -n "$tracking_sdk" || -n "$analytics_sdk" ]]; then
|
|
@@ -818,18 +945,23 @@ fi
|
|
|
818
945
|
# ===================================================================
|
|
819
946
|
# §40 — 5.3.4 Real-money gambling
|
|
820
947
|
# ===================================================================
|
|
948
|
+
set_rule "realmoney-gambling"
|
|
821
949
|
if [[ -d "$META_DIR" ]]; then
|
|
822
950
|
gamble_re='real[ -]?money|gambling|casino|sportsbook|sports[ -]?betting|place[ -].*bets|wager(ing)?|roulette.*real'
|
|
823
951
|
gamble_hits=$(grep -rEniI "$gamble_re" "$META_DIR" 2>/dev/null | grep -v "^Binary file" | head -10)
|
|
824
952
|
if [[ -n "$gamble_hits" ]]; then
|
|
825
|
-
|
|
826
|
-
|
|
953
|
+
gamble_first="$(printf '%s\n' "$gamble_hits" | head -1)" # "path:line:match"
|
|
954
|
+
gamble_file="${gamble_first%%:*}"
|
|
955
|
+
gamble_rest="${gamble_first#*:}"; gamble_line="${gamble_rest%%:*}"
|
|
956
|
+
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"
|
|
957
|
+
detail "$gamble_hits"
|
|
827
958
|
fi
|
|
828
959
|
fi
|
|
829
960
|
|
|
830
961
|
# ===================================================================
|
|
831
962
|
# §41 — 5.5 Mobile Device Management
|
|
832
963
|
# ===================================================================
|
|
964
|
+
set_rule "mdm"
|
|
833
965
|
if [[ -n "$IOS_DIR" ]]; then
|
|
834
966
|
mdm_sig=$(grep -rlE 'import DeviceManagement|MDMConfiguration|ManagedAppConfiguration|com\.apple\.mdm' "$IOS_DIR" --include="*.swift" 2>/dev/null | head -1)
|
|
835
967
|
[[ -z "$mdm_sig" ]] && mdm_sig=$(grep -rlE 'com\.apple\.configuration\.managed' --include='*.plist' "${GREP_PRUNE[@]}" . 2>/dev/null | pick_shallowest)
|
|
@@ -839,3 +971,7 @@ if [[ -n "$IOS_DIR" ]]; then
|
|
|
839
971
|
fi
|
|
840
972
|
|
|
841
973
|
echo "---END-OF-SCAN---"
|
|
974
|
+
if [[ "$FORMAT" == text && "${_SUPPRESSED_COUNT:-0}" -gt 0 ]]; then
|
|
975
|
+
printf '(%s finding(s) suppressed via .precheck-ignore)\n' "$_SUPPRESSED_COUNT"
|
|
976
|
+
fi
|
|
977
|
+
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
|
+
}
|