@mmerterden/dev-toolkit-mcp 2.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +708 -0
- package/LICENSE +21 -0
- package/README.md +387 -0
- package/index.js +1277 -0
- package/package.json +80 -0
- package/tools/design-check/content-cardinality.js +204 -0
- package/tools/design-check/geometry.js +140 -0
- package/tools/design-check/index.js +213 -0
- package/tools/design-check/mock-detect.js +213 -0
- package/tools/design-check/report.js +590 -0
- package/tools/design-check/scan.js +91 -0
- package/tools/design-check/scenario-inventory.js +598 -0
- package/tools/design-check/visual-compare.js +961 -0
- package/tools/ios-app-store-audit/context.js +180 -0
- package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
- package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
- package/tools/ios-app-store-audit/index.js +164 -0
- package/tools/ios-app-store-audit/models.js +47 -0
- package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
- package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
- package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
- package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
- package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
- package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
- package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
- package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
- package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
- package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
- package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
- package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
- package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
- package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
- package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
- package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
- package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
- package/tools/ios-app-store-audit/rules/team-id.js +62 -0
- package/tools/ios-testflight/index.js +479 -0
- package/ui-tree-dumper.swift +122 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* privacy-manifest - Validates PrivacyInfo.xcprivacy presence and per-key
|
|
3
|
+
* completeness against Apple's Required Reason API + Tracking schema.
|
|
4
|
+
* Direct port of PrivacyManifestRule.swift.
|
|
5
|
+
*
|
|
6
|
+
* Apple Guideline: 5.1.1.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Severity, makeViolation } from "../models.js";
|
|
10
|
+
|
|
11
|
+
export const id = "privacy-manifest";
|
|
12
|
+
export const name = "Privacy Manifest";
|
|
13
|
+
export const description = "Checks PrivacyInfo.xcprivacy for required keys and completeness";
|
|
14
|
+
|
|
15
|
+
export function check(ctx) {
|
|
16
|
+
const violations = [];
|
|
17
|
+
const manifest = ctx.privacyManifest;
|
|
18
|
+
|
|
19
|
+
if (!manifest) {
|
|
20
|
+
violations.push(makeViolation({
|
|
21
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
22
|
+
message: "PrivacyInfo.xcprivacy not found in app bundle",
|
|
23
|
+
file: ctx.appPath,
|
|
24
|
+
suggestion: "Add a PrivacyInfo.xcprivacy file to your app target",
|
|
25
|
+
}));
|
|
26
|
+
return violations;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// NSPrivacyTracking
|
|
30
|
+
if (manifest.NSPrivacyTracking == null) {
|
|
31
|
+
violations.push(makeViolation({
|
|
32
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.WARNING,
|
|
33
|
+
message: "NSPrivacyTracking key not declared in privacy manifest",
|
|
34
|
+
file: "PrivacyInfo.xcprivacy",
|
|
35
|
+
suggestion: "Add NSPrivacyTracking (Boolean) to your privacy manifest",
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
if (manifest.NSPrivacyTracking === true) {
|
|
39
|
+
const domains = Array.isArray(manifest.NSPrivacyTrackingDomains) ? manifest.NSPrivacyTrackingDomains : [];
|
|
40
|
+
if (domains.length === 0) {
|
|
41
|
+
violations.push(makeViolation({
|
|
42
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
43
|
+
message: "NSPrivacyTracking is true but NSPrivacyTrackingDomains is empty",
|
|
44
|
+
file: "PrivacyInfo.xcprivacy",
|
|
45
|
+
suggestion: "List all tracking domains in NSPrivacyTrackingDomains",
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// NSPrivacyCollectedDataTypes
|
|
51
|
+
if (manifest.NSPrivacyCollectedDataTypes == null) {
|
|
52
|
+
violations.push(makeViolation({
|
|
53
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.WARNING,
|
|
54
|
+
message: "NSPrivacyCollectedDataTypes not declared",
|
|
55
|
+
file: "PrivacyInfo.xcprivacy",
|
|
56
|
+
suggestion: "Declare collected data types even if empty (use empty array)",
|
|
57
|
+
}));
|
|
58
|
+
} else if (Array.isArray(manifest.NSPrivacyCollectedDataTypes)) {
|
|
59
|
+
manifest.NSPrivacyCollectedDataTypes.forEach((dataType, index) => {
|
|
60
|
+
if (dataType?.NSPrivacyCollectedDataType == null) {
|
|
61
|
+
violations.push(makeViolation({
|
|
62
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
63
|
+
message: `Collected data type at index ${index} missing NSPrivacyCollectedDataType key`,
|
|
64
|
+
file: "PrivacyInfo.xcprivacy",
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
if (dataType?.NSPrivacyCollectedDataTypeLinked == null) {
|
|
68
|
+
violations.push(makeViolation({
|
|
69
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.WARNING,
|
|
70
|
+
message: `Collected data type at index ${index} missing NSPrivacyCollectedDataTypeLinked`,
|
|
71
|
+
file: "PrivacyInfo.xcprivacy",
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
if (dataType?.NSPrivacyCollectedDataTypeTracking == null) {
|
|
75
|
+
violations.push(makeViolation({
|
|
76
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.WARNING,
|
|
77
|
+
message: `Collected data type at index ${index} missing NSPrivacyCollectedDataTypeTracking`,
|
|
78
|
+
file: "PrivacyInfo.xcprivacy",
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
if (dataType?.NSPrivacyCollectedDataTypePurposes == null) {
|
|
82
|
+
violations.push(makeViolation({
|
|
83
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
84
|
+
message: `Collected data type at index ${index} missing NSPrivacyCollectedDataTypePurposes`,
|
|
85
|
+
file: "PrivacyInfo.xcprivacy",
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// NSPrivacyAccessedAPITypes
|
|
92
|
+
if (manifest.NSPrivacyAccessedAPITypes == null) {
|
|
93
|
+
violations.push(makeViolation({
|
|
94
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.WARNING,
|
|
95
|
+
message: "NSPrivacyAccessedAPITypes not declared",
|
|
96
|
+
file: "PrivacyInfo.xcprivacy",
|
|
97
|
+
suggestion: "Declare accessed API types with required reason codes",
|
|
98
|
+
}));
|
|
99
|
+
} else if (Array.isArray(manifest.NSPrivacyAccessedAPITypes)) {
|
|
100
|
+
manifest.NSPrivacyAccessedAPITypes.forEach((apiType, index) => {
|
|
101
|
+
if (apiType?.NSPrivacyAccessedAPIType == null) {
|
|
102
|
+
violations.push(makeViolation({
|
|
103
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
104
|
+
message: `Accessed API type at index ${index} missing NSPrivacyAccessedAPIType key`,
|
|
105
|
+
file: "PrivacyInfo.xcprivacy",
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
const reasons = Array.isArray(apiType?.NSPrivacyAccessedAPITypeReasons) ? apiType.NSPrivacyAccessedAPITypeReasons : [];
|
|
109
|
+
if (reasons.length === 0) {
|
|
110
|
+
violations.push(makeViolation({
|
|
111
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
112
|
+
message: `Accessed API type at index ${index} has no reason codes`,
|
|
113
|
+
file: "PrivacyInfo.xcprivacy",
|
|
114
|
+
suggestion: "Each accessed API type must have at least one valid reason code",
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return violations;
|
|
121
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* production-hygiene - Detects debug logging compiled into release binaries
|
|
3
|
+
* (print, NSLog, debugPrint, dump, CocoaLumberjack DDLog wrappers) plus
|
|
4
|
+
* test/mock/debug data files that should not ship to production.
|
|
5
|
+
* Direct port of ProductionHygieneRule.swift.
|
|
6
|
+
*
|
|
7
|
+
* Apple Guidelines: 2.1 (App Completeness), 2.5.1 (Software Requirements).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
12
|
+
import { join, basename, extname } from "path";
|
|
13
|
+
import { Severity, makeViolation } from "../models.js";
|
|
14
|
+
|
|
15
|
+
export const id = "production-hygiene";
|
|
16
|
+
export const name = "Production Hygiene";
|
|
17
|
+
export const description = "Detects print/NSLog statements in binary and test/mock data files that should not ship to production";
|
|
18
|
+
|
|
19
|
+
const LOGGING_SYMBOLS = [
|
|
20
|
+
// Swift print family - write to stdout, leak data
|
|
21
|
+
{ symbol: "Swift.print(", label: "Swift print()", severity: Severity.WARNING },
|
|
22
|
+
{ symbol: "Swift._print(", label: "Swift print()", severity: Severity.WARNING },
|
|
23
|
+
{ symbol: "_swift_stdlib_print", label: "Swift print()", severity: Severity.WARNING },
|
|
24
|
+
{ symbol: "debugPrint(", label: "debugPrint()", severity: Severity.WARNING },
|
|
25
|
+
{ symbol: "Swift.debugPrint(", label: "Swift debugPrint()", severity: Severity.WARNING },
|
|
26
|
+
{ symbol: "Swift.dump(", label: "Swift dump()", severity: Severity.WARNING },
|
|
27
|
+
|
|
28
|
+
// NSLog - visible in Console.app
|
|
29
|
+
{ symbol: "NSLog", label: "NSLog()", severity: Severity.WARNING },
|
|
30
|
+
{ symbol: "CFLog", label: "CFLog()", severity: Severity.WARNING },
|
|
31
|
+
|
|
32
|
+
// Common debug print wrappers
|
|
33
|
+
{ symbol: "DLog(", label: "DLog() macro", severity: Severity.INFO },
|
|
34
|
+
{ symbol: "ALog(", label: "ALog() macro", severity: Severity.INFO },
|
|
35
|
+
{ symbol: "DDLog", label: "CocoaLumberjack DDLog", severity: Severity.INFO },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const DEBUG_FILE_PATTERNS = [
|
|
39
|
+
{ pattern: "mock", label: "Mock data file" },
|
|
40
|
+
{ pattern: "Mock", label: "Mock data file" },
|
|
41
|
+
{ pattern: "MOCK", label: "Mock data file" },
|
|
42
|
+
{ pattern: "stub", label: "Stub data file" },
|
|
43
|
+
{ pattern: "Stub", label: "Stub data file" },
|
|
44
|
+
{ pattern: "fake", label: "Fake data file" },
|
|
45
|
+
{ pattern: "Fake", label: "Fake data file" },
|
|
46
|
+
{ pattern: "dummy", label: "Dummy data file" },
|
|
47
|
+
{ pattern: "Dummy", label: "Dummy data file" },
|
|
48
|
+
{ pattern: "test", label: "Test data file" },
|
|
49
|
+
{ pattern: "Test", label: "Test data file" },
|
|
50
|
+
{ pattern: "sample", label: "Sample data file" },
|
|
51
|
+
{ pattern: "Sample", label: "Sample data file" },
|
|
52
|
+
{ pattern: "example", label: "Example data file" },
|
|
53
|
+
{ pattern: "Example", label: "Example data file" },
|
|
54
|
+
{ pattern: "debug", label: "Debug data file" },
|
|
55
|
+
{ pattern: "Debug", label: "Debug data file" },
|
|
56
|
+
{ pattern: "DEBUG", label: "Debug data file" },
|
|
57
|
+
{ pattern: "dev_", label: "Development data file" },
|
|
58
|
+
{ pattern: "staging", label: "Staging data file" },
|
|
59
|
+
{ pattern: "Staging", label: "Staging data file" },
|
|
60
|
+
{ pattern: "fixture", label: "Test fixture file" },
|
|
61
|
+
{ pattern: "Fixture", label: "Test fixture file" },
|
|
62
|
+
{ pattern: "seed", label: "Seed data file" },
|
|
63
|
+
{ pattern: "Seed", label: "Seed data file" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const DATA_FILE_EXTENSIONS = new Set(["json", "plist", "xml", "csv", "txt", "yaml", "yml", "sqlite", "db"]);
|
|
67
|
+
|
|
68
|
+
const SYSTEM_FILES = new Set([
|
|
69
|
+
"Info.plist", "Root.plist", "Settings.plist",
|
|
70
|
+
"Assets.car", "PkgInfo", "CodeResources",
|
|
71
|
+
"embedded.mobileprovision", "PrivacyInfo.xcprivacy",
|
|
72
|
+
"ResourceRules.plist", "Entitlements.plist",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
function isSystemFile(filename, relativePath) {
|
|
76
|
+
if (SYSTEM_FILES.has(filename)) return true;
|
|
77
|
+
if (relativePath.includes(".framework/")) return true;
|
|
78
|
+
if (relativePath.includes(".appex/")) return true;
|
|
79
|
+
if (filename.endsWith(".strings") || filename.endsWith(".stringsdict")) return true;
|
|
80
|
+
if (relativePath.includes(".lproj/")) return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function* walk(root, prefix = "") {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = readdirSync(root);
|
|
88
|
+
} catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
const full = join(root, e);
|
|
93
|
+
const rel = prefix ? `${prefix}/${e}` : e;
|
|
94
|
+
let st;
|
|
95
|
+
try {
|
|
96
|
+
st = statSync(full);
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (st.isDirectory()) {
|
|
101
|
+
yield* walk(full, rel);
|
|
102
|
+
} else if (st.isFile()) {
|
|
103
|
+
yield { rel, full };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkLoggingSymbols(ctx) {
|
|
109
|
+
const violations = [];
|
|
110
|
+
if (!ctx.executablePath || !existsSync(ctx.executablePath)) return violations;
|
|
111
|
+
let st;
|
|
112
|
+
try {
|
|
113
|
+
st = statSync(ctx.executablePath);
|
|
114
|
+
} catch {
|
|
115
|
+
return violations;
|
|
116
|
+
}
|
|
117
|
+
if (st.size > 500 * 1024 * 1024) return violations;
|
|
118
|
+
|
|
119
|
+
// Build a single regex alternation for grep - same shape as Swift impl.
|
|
120
|
+
const escapedPatterns = LOGGING_SYMBOLS.map((s) =>
|
|
121
|
+
s.symbol
|
|
122
|
+
.replace(/\(/g, "\\(")
|
|
123
|
+
.replace(/\)/g, "\\)")
|
|
124
|
+
.replace(/\./g, "\\.")
|
|
125
|
+
);
|
|
126
|
+
const grepPattern = escapedPatterns.join("|");
|
|
127
|
+
let output = "";
|
|
128
|
+
try {
|
|
129
|
+
output = execSync(
|
|
130
|
+
`/usr/bin/strings "${ctx.executablePath}" | grep -E '${grepPattern}' 2>/dev/null | head -100`,
|
|
131
|
+
{ encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
|
|
132
|
+
);
|
|
133
|
+
} catch {
|
|
134
|
+
return violations;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const reported = new Set();
|
|
138
|
+
for (const sym of LOGGING_SYMBOLS) {
|
|
139
|
+
if (output.includes(sym.symbol) && !reported.has(sym.label)) {
|
|
140
|
+
reported.add(sym.label);
|
|
141
|
+
violations.push(makeViolation({
|
|
142
|
+
ruleID: id,
|
|
143
|
+
guidelineRef: "2.1",
|
|
144
|
+
severity: sym.severity,
|
|
145
|
+
message: `${sym.label} detected in binary - may leak sensitive data to device console`,
|
|
146
|
+
suggestion: "Replace with os_log or Logger (os.Logger) with appropriate log levels, or guard with #if DEBUG",
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return violations;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function checkDebugDataFiles(ctx) {
|
|
154
|
+
const violations = [];
|
|
155
|
+
const hits = []; // { path, label }
|
|
156
|
+
for (const { rel } of walk(ctx.appPath)) {
|
|
157
|
+
const filename = basename(rel);
|
|
158
|
+
const ext = extname(filename).slice(1).toLowerCase();
|
|
159
|
+
if (!DATA_FILE_EXTENSIONS.has(ext)) continue;
|
|
160
|
+
if (isSystemFile(filename, rel)) continue;
|
|
161
|
+
const nameWithoutExt = filename.slice(0, filename.length - ext.length - 1);
|
|
162
|
+
for (const p of DEBUG_FILE_PATTERNS) {
|
|
163
|
+
if (nameWithoutExt.includes(p.pattern)) {
|
|
164
|
+
hits.push({ path: rel, label: p.label });
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (hits.length === 0) return violations;
|
|
171
|
+
|
|
172
|
+
// Group by label
|
|
173
|
+
const grouped = new Map();
|
|
174
|
+
for (const h of hits) {
|
|
175
|
+
if (!grouped.has(h.label)) grouped.set(h.label, []);
|
|
176
|
+
grouped.get(h.label).push(h.path);
|
|
177
|
+
}
|
|
178
|
+
const sortedLabels = [...grouped.keys()].sort();
|
|
179
|
+
for (const label of sortedLabels) {
|
|
180
|
+
const files = grouped.get(label);
|
|
181
|
+
const sample = files.slice(0, 5).join(", ");
|
|
182
|
+
const extra = files.length > 5 ? ` and ${files.length - 5} more` : "";
|
|
183
|
+
violations.push(makeViolation({
|
|
184
|
+
ruleID: id,
|
|
185
|
+
guidelineRef: "2.1",
|
|
186
|
+
severity: Severity.WARNING,
|
|
187
|
+
message: `${label}(s) found in app bundle: ${sample}${extra}`,
|
|
188
|
+
file: ctx.appPath,
|
|
189
|
+
suggestion: "Remove test/mock/debug data files from the Release build target membership",
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
return violations;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function checkFrameworkJSONFiles(ctx) {
|
|
196
|
+
const violations = [];
|
|
197
|
+
const frameworksDir = join(ctx.appPath, "Frameworks");
|
|
198
|
+
if (!existsSync(frameworksDir)) return violations;
|
|
199
|
+
|
|
200
|
+
let frameworks = [];
|
|
201
|
+
try {
|
|
202
|
+
frameworks = readdirSync(frameworksDir);
|
|
203
|
+
} catch {
|
|
204
|
+
return violations;
|
|
205
|
+
}
|
|
206
|
+
for (const framework of frameworks) {
|
|
207
|
+
if (!framework.endsWith(".framework")) continue;
|
|
208
|
+
const fwPath = join(frameworksDir, framework);
|
|
209
|
+
let contents = [];
|
|
210
|
+
try {
|
|
211
|
+
contents = readdirSync(fwPath);
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const jsonFiles = contents.filter((f) => f.endsWith(".json"));
|
|
216
|
+
if (jsonFiles.length === 0) continue;
|
|
217
|
+
const sample = jsonFiles.slice(0, 5).join(", ");
|
|
218
|
+
const extra = jsonFiles.length > 5 ? ` and ${jsonFiles.length - 5} more` : "";
|
|
219
|
+
violations.push(makeViolation({
|
|
220
|
+
ruleID: id,
|
|
221
|
+
guidelineRef: "2.1",
|
|
222
|
+
severity: Severity.WARNING,
|
|
223
|
+
message: `${framework} contains ${jsonFiles.length} JSON file(s): ${sample}${extra}`,
|
|
224
|
+
file: fwPath,
|
|
225
|
+
suggestion: "Verify these JSON files are needed in production. Mock/stub data should be excluded from Release builds",
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
return violations;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function check(ctx) {
|
|
232
|
+
return [
|
|
233
|
+
...checkLoggingSymbols(ctx),
|
|
234
|
+
...checkDebugDataFiles(ctx),
|
|
235
|
+
...checkFrameworkJSONFiles(ctx),
|
|
236
|
+
];
|
|
237
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provisioning-profile - Validates embedded.mobileprovision: expiry,
|
|
3
|
+
* profile type (Distribution / Development / Enterprise / Ad Hoc).
|
|
4
|
+
* Direct port of ProvisioningProfileRule.swift.
|
|
5
|
+
*
|
|
6
|
+
* ITMS error codes: 90034, 90161
|
|
7
|
+
*
|
|
8
|
+
* The profile is a CMS-signed plist; decode it with `security cms -D -i`,
|
|
9
|
+
* then pipe through `plutil -convert json` because Apple ships a mix of
|
|
10
|
+
* binary / XML plists across Xcode versions.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
import { existsSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { Severity, makeViolation } from "../models.js";
|
|
17
|
+
|
|
18
|
+
export const id = "provisioning-profile";
|
|
19
|
+
export const name = "Provisioning Profile";
|
|
20
|
+
export const description = "Checks embedded.mobileprovision for expiry date and distribution type";
|
|
21
|
+
|
|
22
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
function decodeProfile(profilePath) {
|
|
25
|
+
try {
|
|
26
|
+
// CMS decode → produces plist bytes on stdout (XML/binary depending on Xcode).
|
|
27
|
+
const cmsXml = execSync(`/usr/bin/security cms -D -i "${profilePath}" 2>/dev/null`, {
|
|
28
|
+
encoding: "utf-8",
|
|
29
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
30
|
+
});
|
|
31
|
+
if (!cmsXml.trim()) return null;
|
|
32
|
+
// Convert to JSON for predictable typed access.
|
|
33
|
+
const json = execSync(`plutil -convert json -o - -`, {
|
|
34
|
+
input: cmsXml,
|
|
35
|
+
encoding: "utf-8",
|
|
36
|
+
}).trim();
|
|
37
|
+
return JSON.parse(json);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isoDate(input) {
|
|
44
|
+
if (!input) return null;
|
|
45
|
+
// plutil -convert json renders Date as ISO 8601 string.
|
|
46
|
+
if (typeof input === "string") {
|
|
47
|
+
const t = Date.parse(input);
|
|
48
|
+
return Number.isFinite(t) ? new Date(t) : null;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function check(ctx) {
|
|
54
|
+
const violations = [];
|
|
55
|
+
const provPath = join(ctx.appPath, "embedded.mobileprovision");
|
|
56
|
+
|
|
57
|
+
if (!existsSync(provPath)) {
|
|
58
|
+
violations.push(makeViolation({
|
|
59
|
+
ruleID: id, guidelineRef: "ITMS-90034", severity: Severity.INFO,
|
|
60
|
+
message: "No embedded.mobileprovision found in app bundle",
|
|
61
|
+
file: ctx.appPath,
|
|
62
|
+
suggestion: "Provisioning profile may be stripped for App Store builds - verify signing is correct",
|
|
63
|
+
}));
|
|
64
|
+
return violations;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = decodeProfile(provPath);
|
|
68
|
+
if (!data) {
|
|
69
|
+
violations.push(makeViolation({
|
|
70
|
+
ruleID: id, guidelineRef: "ITMS-90034", severity: Severity.WARNING,
|
|
71
|
+
message: "Could not parse embedded.mobileprovision",
|
|
72
|
+
file: provPath,
|
|
73
|
+
}));
|
|
74
|
+
return violations;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Expiration
|
|
78
|
+
const expDate = isoDate(data.ExpirationDate);
|
|
79
|
+
if (expDate) {
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (expDate.getTime() < now) {
|
|
82
|
+
violations.push(makeViolation({
|
|
83
|
+
ruleID: id, guidelineRef: "ITMS-90034", severity: Severity.ERROR,
|
|
84
|
+
message: `Provisioning profile expired on ${expDate.toISOString()}`,
|
|
85
|
+
file: provPath,
|
|
86
|
+
suggestion: "Renew the provisioning profile in Apple Developer Portal",
|
|
87
|
+
}));
|
|
88
|
+
} else if (expDate.getTime() - now < SEVEN_DAYS_MS) {
|
|
89
|
+
violations.push(makeViolation({
|
|
90
|
+
ruleID: id, severity: Severity.WARNING,
|
|
91
|
+
message: `Provisioning profile expires soon: ${expDate.toISOString()}`,
|
|
92
|
+
file: provPath,
|
|
93
|
+
suggestion: "Consider renewing the provisioning profile before submission",
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Development profile detection (get-task-allow=true inside Entitlements)
|
|
99
|
+
const ents = data.Entitlements;
|
|
100
|
+
if (ents && ents["get-task-allow"] === true) {
|
|
101
|
+
violations.push(makeViolation({
|
|
102
|
+
ruleID: id, guidelineRef: "ITMS-90034", severity: Severity.ERROR,
|
|
103
|
+
message: "Provisioning profile is a Development profile (not App Store Distribution)",
|
|
104
|
+
file: provPath,
|
|
105
|
+
suggestion: "Use an App Store Distribution provisioning profile for submission",
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Enterprise (In-House) - ProvisionsAllDevices set
|
|
110
|
+
if (data.ProvisionsAllDevices != null) {
|
|
111
|
+
violations.push(makeViolation({
|
|
112
|
+
ruleID: id, severity: Severity.WARNING,
|
|
113
|
+
message: "Provisioning profile is an Enterprise (In-House) profile",
|
|
114
|
+
file: provPath,
|
|
115
|
+
suggestion: "Use an App Store Distribution profile for App Store submission",
|
|
116
|
+
}));
|
|
117
|
+
} else if (Array.isArray(data.ProvisionedDevices) && data.ProvisionedDevices.length > 0) {
|
|
118
|
+
violations.push(makeViolation({
|
|
119
|
+
ruleID: id, severity: Severity.WARNING,
|
|
120
|
+
message: `Provisioning profile is Ad Hoc (lists ${data.ProvisionedDevices.length} devices)`,
|
|
121
|
+
file: provPath,
|
|
122
|
+
suggestion: "Use an App Store Distribution profile for App Store submission",
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return violations;
|
|
127
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* required-reason-api - Cross-references binary symbol usage with the
|
|
3
|
+
* privacy manifest's NSPrivacyAccessedAPITypes declarations.
|
|
4
|
+
* Direct port of RequiredReasonAPIRule.swift + BinaryAnalyzer category logic.
|
|
5
|
+
*
|
|
6
|
+
* Apple Guideline: 5.1.1 (Privacy Manifest - Required Reason APIs).
|
|
7
|
+
* If the binary uses an API in one of the 5 categories but no declaration
|
|
8
|
+
* is present, App Review rejects with ITMS-91065 / 91066 errors.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "child_process";
|
|
12
|
+
import { existsSync } from "fs";
|
|
13
|
+
import { Severity, makeViolation } from "../models.js";
|
|
14
|
+
|
|
15
|
+
export const id = "required-reason-api";
|
|
16
|
+
export const name = "Required Reason API";
|
|
17
|
+
export const description = "Cross-references binary API usage with privacy manifest declarations";
|
|
18
|
+
|
|
19
|
+
const CATEGORIES = [
|
|
20
|
+
{
|
|
21
|
+
category: "NSPrivacyAccessedAPICategoryFileTimestamp",
|
|
22
|
+
symbols: [
|
|
23
|
+
"creationDate", "modificationDate", "contentModificationDateKey",
|
|
24
|
+
"creationDateKey", "NSFileCreationDate", "NSFileModificationDate",
|
|
25
|
+
"getattrlist", "fgetattrlist", "stat", "fstat", "lstat",
|
|
26
|
+
],
|
|
27
|
+
validCodes: ["C617.1", "3B52.1", "0A2A.1", "DDA9.1"],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
category: "NSPrivacyAccessedAPICategorySystemBootTime",
|
|
31
|
+
symbols: [
|
|
32
|
+
"systemUptime", "mach_absolute_time", "kern.boottime",
|
|
33
|
+
"ProcessInfo.processInfo.systemUptime",
|
|
34
|
+
],
|
|
35
|
+
validCodes: ["35F9.1", "8FFB.1", "3D61.1"],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
category: "NSPrivacyAccessedAPICategoryDiskSpace",
|
|
39
|
+
symbols: [
|
|
40
|
+
"volumeAvailableCapacityKey", "volumeTotalCapacityKey",
|
|
41
|
+
"volumeAvailableCapacityForImportantUsageKey",
|
|
42
|
+
"volumeAvailableCapacityForOpportunisticUsageKey",
|
|
43
|
+
"NSFileSystemFreeSize", "NSFileSystemSize",
|
|
44
|
+
"statfs", "statvfs", "fstatfs", "fstatvfs",
|
|
45
|
+
],
|
|
46
|
+
validCodes: ["E174.1", "85F4.1", "AB6B.1", "7D9E.1"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
category: "NSPrivacyAccessedAPICategoryUserDefaults",
|
|
50
|
+
symbols: ["NSUserDefaults", "UserDefaults.standard"],
|
|
51
|
+
validCodes: ["CA92.1", "1C8F.1", "C56D.1"],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
category: "NSPrivacyAccessedAPICategoryActiveKeyboards",
|
|
55
|
+
symbols: ["activeInputModes"],
|
|
56
|
+
validCodes: ["3EC4.1", "54BD.1"],
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function binaryStringsBlob(executablePath) {
|
|
61
|
+
try {
|
|
62
|
+
return execSync(`/usr/bin/strings "${executablePath}"`, {
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function declaredAPIs(manifest) {
|
|
72
|
+
const map = new Map();
|
|
73
|
+
if (!manifest || !Array.isArray(manifest.NSPrivacyAccessedAPITypes)) return map;
|
|
74
|
+
for (const apiType of manifest.NSPrivacyAccessedAPITypes) {
|
|
75
|
+
const cat = apiType?.NSPrivacyAccessedAPIType;
|
|
76
|
+
const reasons = apiType?.NSPrivacyAccessedAPITypeReasons;
|
|
77
|
+
if (typeof cat === "string" && Array.isArray(reasons)) {
|
|
78
|
+
map.set(cat, reasons.filter((r) => typeof r === "string"));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function check(ctx) {
|
|
85
|
+
const violations = [];
|
|
86
|
+
if (!ctx.executablePath || !existsSync(ctx.executablePath)) return violations;
|
|
87
|
+
|
|
88
|
+
const blob = binaryStringsBlob(ctx.executablePath);
|
|
89
|
+
if (!blob) return violations;
|
|
90
|
+
|
|
91
|
+
const declared = declaredAPIs(ctx.privacyManifest);
|
|
92
|
+
|
|
93
|
+
for (const cat of CATEGORIES) {
|
|
94
|
+
const matchedSymbols = cat.symbols.filter((s) => blob.includes(s));
|
|
95
|
+
if (matchedSymbols.length === 0) continue;
|
|
96
|
+
|
|
97
|
+
const declaredReasons = declared.get(cat.category);
|
|
98
|
+
if (declaredReasons == null) {
|
|
99
|
+
// Used but not declared.
|
|
100
|
+
violations.push(makeViolation({
|
|
101
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
102
|
+
message: `Binary uses ${cat.category} APIs (${matchedSymbols.slice(0, 3).join(", ")}) but no declaration in privacy manifest`,
|
|
103
|
+
file: "PrivacyInfo.xcprivacy",
|
|
104
|
+
suggestion: `Add ${cat.category} with appropriate reason codes to NSPrivacyAccessedAPITypes`,
|
|
105
|
+
}));
|
|
106
|
+
} else {
|
|
107
|
+
// Declared - validate reason codes against Apple's whitelist.
|
|
108
|
+
const validSet = new Set(cat.validCodes);
|
|
109
|
+
for (const reason of declaredReasons) {
|
|
110
|
+
if (!validSet.has(reason)) {
|
|
111
|
+
violations.push(makeViolation({
|
|
112
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
113
|
+
message: `Invalid reason code '${reason}' for ${cat.category}`,
|
|
114
|
+
file: "PrivacyInfo.xcprivacy",
|
|
115
|
+
suggestion: `Valid codes: ${[...validSet].sort().join(", ")}`,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return violations;
|
|
123
|
+
}
|