@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,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embedded-sdk - Validates embedded frameworks for code signing, simulator
|
|
3
|
+
* architectures, and privacy manifest presence (for Apple's required SDK list).
|
|
4
|
+
* Direct port of EmbeddedSDKRule.swift.
|
|
5
|
+
*
|
|
6
|
+
* ITMS error codes: 91065 (signing), 90174 (sim slices). Apple Guideline 5.1.1.
|
|
7
|
+
*
|
|
8
|
+
* Source list: https://developer.apple.com/support/third-party-SDK-requirements/
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "child_process";
|
|
12
|
+
import { existsSync, readFileSync } from "fs";
|
|
13
|
+
import { dirname, basename, join } from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import { Severity, makeViolation } from "../models.js";
|
|
16
|
+
|
|
17
|
+
export const id = "embedded-sdk";
|
|
18
|
+
export const name = "Embedded SDK Validation";
|
|
19
|
+
export const description = "Checks embedded frameworks for signing, simulator slices, and privacy manifests";
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const SDK_LIST_PATH = join(__dirname, "../data/apple-required-sdks.json");
|
|
23
|
+
|
|
24
|
+
let APPLE_REQUIRED_SDKS = null;
|
|
25
|
+
function loadSDKs() {
|
|
26
|
+
if (APPLE_REQUIRED_SDKS !== null) return APPLE_REQUIRED_SDKS;
|
|
27
|
+
try {
|
|
28
|
+
const data = JSON.parse(readFileSync(SDK_LIST_PATH, "utf-8"));
|
|
29
|
+
APPLE_REQUIRED_SDKS = new Set(data.sdks || []);
|
|
30
|
+
} catch {
|
|
31
|
+
APPLE_REQUIRED_SDKS = new Set();
|
|
32
|
+
}
|
|
33
|
+
return APPLE_REQUIRED_SDKS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isAppleRequiredSDK(frameworkName) {
|
|
37
|
+
const list = loadSDKs();
|
|
38
|
+
const stripped = frameworkName.replace(/\.framework$/, "").replace(/\.xcframework$/, "");
|
|
39
|
+
if (list.has(stripped)) return true;
|
|
40
|
+
// SPM mangled / repackaged matches: name starts with or contains an SDK token.
|
|
41
|
+
for (const sdk of list) {
|
|
42
|
+
if (stripped.startsWith(sdk) || stripped.includes(sdk)) return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isCodeSigned(path) {
|
|
48
|
+
try {
|
|
49
|
+
execSync(`/usr/bin/codesign --verify --deep --strict "${path}"`, { stdio: "ignore" });
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function simulatorArchitectures(binaryPath) {
|
|
57
|
+
if (!existsSync(binaryPath)) return [];
|
|
58
|
+
let archs = "";
|
|
59
|
+
try {
|
|
60
|
+
archs = execSync(`/usr/bin/lipo -archs "${binaryPath}" 2>/dev/null`, {
|
|
61
|
+
encoding: "utf-8",
|
|
62
|
+
}).trim();
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
return archs.split(/\s+/).map((a) => a.trim()).filter((a) => a === "x86_64" || a === "i386");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function check(ctx) {
|
|
70
|
+
const violations = [];
|
|
71
|
+
const archiveSigned = ctx.executablePath ? isCodeSigned(ctx.executablePath) : false;
|
|
72
|
+
|
|
73
|
+
if (!archiveSigned && (ctx.embeddedFrameworks?.length ?? 0) > 0) {
|
|
74
|
+
violations.push(makeViolation({
|
|
75
|
+
ruleID: id, severity: Severity.INFO,
|
|
76
|
+
message: "Archive is not signed - skipping framework signature checks. Sign the archive to enable full validation.",
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const fwPath of ctx.embeddedFrameworks ?? []) {
|
|
81
|
+
const fwName = basename(fwPath);
|
|
82
|
+
const required = isAppleRequiredSDK(fwName);
|
|
83
|
+
|
|
84
|
+
// Signature check (only for signed archives, only error for required SDKs)
|
|
85
|
+
if (archiveSigned && !isCodeSigned(fwPath) && required) {
|
|
86
|
+
violations.push(makeViolation({
|
|
87
|
+
ruleID: id, guidelineRef: "ITMS-91065", severity: Severity.ERROR,
|
|
88
|
+
message: `'${fwName}' is on Apple's required SDK list and is not signed`,
|
|
89
|
+
file: fwPath,
|
|
90
|
+
suggestion: "Contact the SDK vendor for a signed version. See: https://developer.apple.com/support/third-party-SDK-requirements/",
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Simulator architectures (always error if found)
|
|
95
|
+
const binaryName = fwName.replace(/\.framework$/, "").replace(/\.xcframework$/, "");
|
|
96
|
+
const binaryPath = join(fwPath, binaryName);
|
|
97
|
+
if (existsSync(binaryPath)) {
|
|
98
|
+
const simArchs = simulatorArchitectures(binaryPath);
|
|
99
|
+
if (simArchs.length > 0) {
|
|
100
|
+
violations.push(makeViolation({
|
|
101
|
+
ruleID: id, guidelineRef: "ITMS-90174", severity: Severity.ERROR,
|
|
102
|
+
message: `Framework '${fwName}' contains simulator architectures: ${simArchs.join(", ")}`,
|
|
103
|
+
file: fwPath,
|
|
104
|
+
suggestion: "Strip simulator architectures before submission",
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A required-SDK framework with no PrivacyInfo.xcprivacy is ITMS-91061, an
|
|
110
|
+
// enforced rejection since 2025-02-12 - not a 5.1.1 advisory. Filing it as
|
|
111
|
+
// a WARNING let a hard rejection pass the audit.
|
|
112
|
+
if (required) {
|
|
113
|
+
const manifestPath = join(fwPath, "PrivacyInfo.xcprivacy");
|
|
114
|
+
if (!existsSync(manifestPath)) {
|
|
115
|
+
violations.push(makeViolation({
|
|
116
|
+
ruleID: id, guidelineRef: "ITMS-91061", severity: Severity.ERROR,
|
|
117
|
+
message: `'${fwName}' is on Apple's required SDK list but missing PrivacyInfo.xcprivacy (ITMS-91061)`,
|
|
118
|
+
file: fwPath,
|
|
119
|
+
suggestion: "Upgrade to an SDK version that ships a privacy manifest, or request one from the vendor. App Store Connect rejects the upload without it. See: https://developer.apple.com/support/third-party-SDK-requirements/",
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return violations;
|
|
126
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entitlement - Validates entitlements consistency + companion Info.plist
|
|
3
|
+
* purpose strings + background modes + keychain group prefixes.
|
|
4
|
+
* Direct port of EntitlementRule.swift.
|
|
5
|
+
*
|
|
6
|
+
* Apple Guidelines: 2.5.4, 5.1.1, ITMS-90046
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Severity, makeViolation } from "../models.js";
|
|
10
|
+
|
|
11
|
+
export const id = "entitlements";
|
|
12
|
+
export const name = "Entitlements Validation";
|
|
13
|
+
export const description = "Checks entitlements for consistency and required companion Info.plist keys";
|
|
14
|
+
|
|
15
|
+
const ENT_PURPOSE_MAP = [
|
|
16
|
+
{ entitlement: "com.apple.developer.healthkit", plistKey: "NSHealthShareUsageDescription", label: "HealthKit" },
|
|
17
|
+
{ entitlement: "com.apple.developer.healthkit", plistKey: "NSHealthUpdateUsageDescription", label: "HealthKit" },
|
|
18
|
+
{ entitlement: "com.apple.developer.networking.wifi-info", plistKey: "NSLocalNetworkUsageDescription", label: "Wi-Fi Info" },
|
|
19
|
+
{ entitlement: "com.apple.developer.nfc.readersession.formats", plistKey: "NFCReaderUsageDescription", label: "NFC" },
|
|
20
|
+
{ entitlement: "com.apple.developer.homekit", plistKey: "NSHomeKitUsageDescription", label: "HomeKit" },
|
|
21
|
+
{ entitlement: "com.apple.developer.siri", plistKey: "NSSiriUsageDescription", label: "Siri" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const BACKGROUND_MODES = {
|
|
25
|
+
audio: "Background Audio",
|
|
26
|
+
location: "Background Location",
|
|
27
|
+
voip: "VoIP",
|
|
28
|
+
fetch: "Background Fetch",
|
|
29
|
+
"remote-notification": "Remote Notifications",
|
|
30
|
+
"bluetooth-central": "Bluetooth Central",
|
|
31
|
+
"bluetooth-peripheral": "Bluetooth Peripheral",
|
|
32
|
+
"external-accessory": "External Accessory",
|
|
33
|
+
processing: "Background Processing",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function check(ctx) {
|
|
37
|
+
const violations = [];
|
|
38
|
+
const ent = ctx.entitlements;
|
|
39
|
+
const plist = ctx.infoPlist || {};
|
|
40
|
+
if (!ent) return violations;
|
|
41
|
+
|
|
42
|
+
// Entitlement → Info.plist purpose string mapping
|
|
43
|
+
for (const m of ENT_PURPOSE_MAP) {
|
|
44
|
+
if (ent[m.entitlement] != null && plist[m.plistKey] == null) {
|
|
45
|
+
violations.push(makeViolation({
|
|
46
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
47
|
+
message: `${m.label} entitlement requires ${m.plistKey} in Info.plist`,
|
|
48
|
+
suggestion: `Add ${m.plistKey} with a meaningful description to Info.plist`,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Background modes
|
|
54
|
+
const bgModes = Array.isArray(plist.UIBackgroundModes) ? plist.UIBackgroundModes : null;
|
|
55
|
+
if (bgModes) {
|
|
56
|
+
for (const mode of bgModes) {
|
|
57
|
+
const label = BACKGROUND_MODES[mode];
|
|
58
|
+
if (label) {
|
|
59
|
+
violations.push(makeViolation({
|
|
60
|
+
ruleID: id, guidelineRef: "2.5.4", severity: Severity.INFO,
|
|
61
|
+
message: `Background mode '${label}' is enabled - Apple reviews these carefully`,
|
|
62
|
+
suggestion: "Ensure this background mode is actively used and justified in App Review notes",
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (bgModes.includes("location") &&
|
|
67
|
+
plist.NSLocationAlwaysAndWhenInUseUsageDescription == null &&
|
|
68
|
+
plist.NSLocationAlwaysUsageDescription == null) {
|
|
69
|
+
violations.push(makeViolation({
|
|
70
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
71
|
+
message: "Background location mode requires NSLocationAlwaysAndWhenInUseUsageDescription",
|
|
72
|
+
file: "Info.plist",
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Associated domains - present but empty
|
|
78
|
+
const domains = ent["com.apple.developer.associated-domains"];
|
|
79
|
+
if (Array.isArray(domains) && domains.length === 0) {
|
|
80
|
+
violations.push(makeViolation({
|
|
81
|
+
ruleID: id, guidelineRef: "2.1", severity: Severity.WARNING,
|
|
82
|
+
message: "Associated domains entitlement is present but the domain list is empty",
|
|
83
|
+
suggestion: "Add your universal link domains or remove the entitlement",
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Keychain access groups - must share team prefix
|
|
88
|
+
const groups = ent["keychain-access-groups"];
|
|
89
|
+
if (Array.isArray(groups)) {
|
|
90
|
+
const appID = typeof ent["application-identifier"] === "string" ? ent["application-identifier"] : "";
|
|
91
|
+
const teamPrefix = appID.split(".")[0] || "";
|
|
92
|
+
for (const group of groups) {
|
|
93
|
+
if (typeof group !== "string") continue;
|
|
94
|
+
if (!group.startsWith(teamPrefix) && !group.startsWith("$(")) {
|
|
95
|
+
violations.push(makeViolation({
|
|
96
|
+
ruleID: id, guidelineRef: "ITMS-90046", severity: Severity.WARNING,
|
|
97
|
+
message: `Keychain access group '${group}' does not match team prefix '${teamPrefix}'`,
|
|
98
|
+
suggestion: "Verify keychain sharing groups are configured correctly",
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return violations;
|
|
105
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extension-signing - Validates every embedded .appex (app extension /
|
|
3
|
+
* widget / share extension): code signing, bundle-ID prefix consistency,
|
|
4
|
+
* privacy manifest hint, App Groups entitlement match with the main app.
|
|
5
|
+
* Direct port of ExtensionSigningRule.swift.
|
|
6
|
+
*
|
|
7
|
+
* ITMS error codes: 90035, 90046, 90283. Apple Guideline 5.1.1.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from "child_process";
|
|
11
|
+
import { existsSync } from "fs";
|
|
12
|
+
import { join, basename } from "path";
|
|
13
|
+
import { Severity, makeViolation } from "../models.js";
|
|
14
|
+
import { parsePlist, extractEntitlements } from "../context.js";
|
|
15
|
+
|
|
16
|
+
export const id = "extension-signing";
|
|
17
|
+
export const name = "Extension Signing";
|
|
18
|
+
export const description = "Checks embedded extensions for signing, bundle ID prefix, and entitlement consistency";
|
|
19
|
+
|
|
20
|
+
function isCodeSigned(path) {
|
|
21
|
+
try {
|
|
22
|
+
execSync(`/usr/bin/codesign --verify --deep --strict "${path}"`, { stdio: "ignore" });
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function check(ctx) {
|
|
30
|
+
const violations = [];
|
|
31
|
+
const mainBundleID = typeof ctx.infoPlist?.CFBundleIdentifier === "string"
|
|
32
|
+
? ctx.infoPlist.CFBundleIdentifier
|
|
33
|
+
: "";
|
|
34
|
+
|
|
35
|
+
const mainGroups = Array.isArray(ctx.entitlements?.["com.apple.security.application-groups"])
|
|
36
|
+
? ctx.entitlements["com.apple.security.application-groups"]
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
for (const pluginPath of ctx.embeddedPlugins ?? []) {
|
|
40
|
+
const name = basename(pluginPath);
|
|
41
|
+
|
|
42
|
+
// 1. Code signed?
|
|
43
|
+
if (!isCodeSigned(pluginPath)) {
|
|
44
|
+
violations.push(makeViolation({
|
|
45
|
+
ruleID: id, guidelineRef: "ITMS-90035", severity: Severity.ERROR,
|
|
46
|
+
message: `Extension '${name}' is not properly code signed`,
|
|
47
|
+
file: pluginPath,
|
|
48
|
+
suggestion: "Ensure the extension target uses the same signing configuration as the main app",
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Bundle-ID prefix
|
|
53
|
+
const extPlistPath = join(pluginPath, "Info.plist");
|
|
54
|
+
const extPlist = parsePlist(extPlistPath);
|
|
55
|
+
const extBundleID = typeof extPlist?.CFBundleIdentifier === "string" ? extPlist.CFBundleIdentifier : "";
|
|
56
|
+
|
|
57
|
+
if (mainBundleID && extBundleID && !extBundleID.startsWith(mainBundleID)) {
|
|
58
|
+
const cleanName = name.replace(/\.appex$/, "");
|
|
59
|
+
violations.push(makeViolation({
|
|
60
|
+
ruleID: id, guidelineRef: "ITMS-90046", severity: Severity.ERROR,
|
|
61
|
+
message: `Extension '${name}' bundle ID '${extBundleID}' must be prefixed with main app bundle ID '${mainBundleID}'`,
|
|
62
|
+
file: extPlistPath,
|
|
63
|
+
suggestion: `Change extension bundle ID to '${mainBundleID}.${cleanName}'`,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Per-extension privacy manifest hint
|
|
68
|
+
if (extPlist?.NSExtensionPointIdentifier != null) {
|
|
69
|
+
const extPrivacyPath = join(pluginPath, "PrivacyInfo.xcprivacy");
|
|
70
|
+
if (!existsSync(extPrivacyPath)) {
|
|
71
|
+
violations.push(makeViolation({
|
|
72
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.INFO,
|
|
73
|
+
message: `Extension '${name}' does not have its own PrivacyInfo.xcprivacy`,
|
|
74
|
+
file: pluginPath,
|
|
75
|
+
suggestion: "Consider adding a privacy manifest to the extension if it accesses required-reason APIs",
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. App Groups consistency vs main app
|
|
81
|
+
if (mainGroups) {
|
|
82
|
+
const extExecName = (typeof extPlist?.CFBundleExecutable === "string"
|
|
83
|
+
? extPlist.CFBundleExecutable
|
|
84
|
+
: name.replace(/\.appex$/, ""));
|
|
85
|
+
const extExecPath = join(pluginPath, extExecName);
|
|
86
|
+
const extEnt = extractEntitlements(extExecPath);
|
|
87
|
+
const extGroups = Array.isArray(extEnt?.["com.apple.security.application-groups"])
|
|
88
|
+
? extEnt["com.apple.security.application-groups"]
|
|
89
|
+
: null;
|
|
90
|
+
if (extGroups) {
|
|
91
|
+
const missing = extGroups.filter((g) => !mainGroups.includes(g));
|
|
92
|
+
if (missing.length > 0) {
|
|
93
|
+
violations.push(makeViolation({
|
|
94
|
+
ruleID: id, guidelineRef: "ITMS-90283", severity: Severity.ERROR,
|
|
95
|
+
message: `Extension '${name}' uses App Groups not in main app: ${missing.join(", ")}`,
|
|
96
|
+
file: pluginPath,
|
|
97
|
+
suggestion: "Add the missing App Groups to the main app target",
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return violations;
|
|
105
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* info-plist - Validates Info.plist for required keys, purpose strings,
|
|
3
|
+
* ATS configuration, app-name length (≤30 chars), and launch storyboard
|
|
4
|
+
* presence. Direct port of InfoPlistRule.swift.
|
|
5
|
+
*
|
|
6
|
+
* Apple Guidelines: 2.1, 2.3, 5.1.1.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Severity, makeViolation } from "../models.js";
|
|
10
|
+
|
|
11
|
+
export const id = "info-plist";
|
|
12
|
+
export const name = "Info.plist Validation";
|
|
13
|
+
export const description = "Checks Info.plist for required keys, purpose strings, and ATS configuration";
|
|
14
|
+
|
|
15
|
+
/** Keys that need a non-empty, meaningful description. */
|
|
16
|
+
const PURPOSE_STRING_KEYS = [
|
|
17
|
+
["NSCameraUsageDescription", "Camera"],
|
|
18
|
+
["NSMicrophoneUsageDescription", "Microphone"],
|
|
19
|
+
["NSPhotoLibraryUsageDescription", "Photo Library"],
|
|
20
|
+
["NSPhotoLibraryAddUsageDescription", "Photo Library Add"],
|
|
21
|
+
["NSLocationWhenInUseUsageDescription", "Location (When In Use)"],
|
|
22
|
+
["NSLocationAlwaysAndWhenInUseUsageDescription", "Location (Always)"],
|
|
23
|
+
["NSLocationAlwaysUsageDescription", "Location (Always - Legacy)"],
|
|
24
|
+
["NSContactsUsageDescription", "Contacts"],
|
|
25
|
+
["NSCalendarsUsageDescription", "Calendars"],
|
|
26
|
+
["NSRemindersUsageDescription", "Reminders"],
|
|
27
|
+
["NSBluetoothAlwaysUsageDescription", "Bluetooth"],
|
|
28
|
+
["NSBluetoothPeripheralUsageDescription", "Bluetooth Peripheral"],
|
|
29
|
+
["NSHealthShareUsageDescription", "HealthKit Share"],
|
|
30
|
+
["NSHealthUpdateUsageDescription", "HealthKit Update"],
|
|
31
|
+
["NSMotionUsageDescription", "Motion"],
|
|
32
|
+
["NSSpeechRecognitionUsageDescription", "Speech Recognition"],
|
|
33
|
+
["NSFaceIDUsageDescription", "Face ID"],
|
|
34
|
+
["NSLocalNetworkUsageDescription", "Local Network"],
|
|
35
|
+
["NSUserTrackingUsageDescription", "App Tracking Transparency"],
|
|
36
|
+
["NSSiriUsageDescription", "Siri"],
|
|
37
|
+
["NSAppleMusicUsageDescription", "Media Library"],
|
|
38
|
+
["NSHomeKitUsageDescription", "HomeKit"],
|
|
39
|
+
["NFCReaderUsageDescription", "NFC"],
|
|
40
|
+
["NSNearbyInteractionUsageDescription", "Nearby Interaction"],
|
|
41
|
+
["NSSensorKitUsageDescription", "SensorKit"],
|
|
42
|
+
["NSWorldSensingUsageDescription", "World Sensing"],
|
|
43
|
+
["NSHandsTrackingUsageDescription", "Hand Tracking"],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const VAGUE_PHRASES = ["need access", "requires access", "needs permission", "this app needs"];
|
|
47
|
+
|
|
48
|
+
export function check(ctx) {
|
|
49
|
+
const violations = [];
|
|
50
|
+
const plist = ctx.infoPlist || {};
|
|
51
|
+
|
|
52
|
+
// Required keys
|
|
53
|
+
if (plist.CFBundleIdentifier == null) {
|
|
54
|
+
violations.push(makeViolation({
|
|
55
|
+
ruleID: id, guidelineRef: "2.1", severity: Severity.ERROR,
|
|
56
|
+
message: "CFBundleIdentifier is missing", file: "Info.plist",
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
if (plist.CFBundleShortVersionString == null) {
|
|
60
|
+
violations.push(makeViolation({
|
|
61
|
+
ruleID: id, guidelineRef: "2.1", severity: Severity.ERROR,
|
|
62
|
+
message: "CFBundleShortVersionString is missing", file: "Info.plist",
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
if (plist.CFBundleVersion == null) {
|
|
66
|
+
violations.push(makeViolation({
|
|
67
|
+
ruleID: id, guidelineRef: "2.1", severity: Severity.ERROR,
|
|
68
|
+
message: "CFBundleVersion is missing", file: "Info.plist",
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
if (plist.CFBundleDisplayName == null && plist.CFBundleName == null) {
|
|
72
|
+
violations.push(makeViolation({
|
|
73
|
+
ruleID: id, guidelineRef: "2.3.7", severity: Severity.WARNING,
|
|
74
|
+
message: "Neither CFBundleDisplayName nor CFBundleName is set", file: "Info.plist",
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// App name length (Apple App Store ≤30 chars)
|
|
79
|
+
const displayName = (plist.CFBundleDisplayName ?? plist.CFBundleName);
|
|
80
|
+
if (typeof displayName === "string" && displayName.length > 30) {
|
|
81
|
+
violations.push(makeViolation({
|
|
82
|
+
ruleID: id, guidelineRef: "2.3.7", severity: Severity.ERROR,
|
|
83
|
+
message: `App name '${displayName}' exceeds 30 character limit (${displayName.length} chars)`,
|
|
84
|
+
file: "Info.plist",
|
|
85
|
+
suggestion: "Shorten the app name to 30 characters or fewer",
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Purpose strings - empty / vague checks
|
|
90
|
+
for (const [key, framework] of PURPOSE_STRING_KEYS) {
|
|
91
|
+
const value = plist[key];
|
|
92
|
+
if (typeof value !== "string") continue;
|
|
93
|
+
const trimmed = value.trim();
|
|
94
|
+
if (trimmed === "") {
|
|
95
|
+
violations.push(makeViolation({
|
|
96
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.ERROR,
|
|
97
|
+
message: `${key} (${framework}) is present but empty`,
|
|
98
|
+
file: "Info.plist",
|
|
99
|
+
suggestion: `Provide a meaningful description explaining why your app needs ${framework} access`,
|
|
100
|
+
}));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const lowered = value.toLowerCase();
|
|
104
|
+
if (VAGUE_PHRASES.some((p) => lowered.includes(p)) && value.length < 50) {
|
|
105
|
+
violations.push(makeViolation({
|
|
106
|
+
ruleID: id, guidelineRef: "5.1.1", severity: Severity.WARNING,
|
|
107
|
+
message: `${key} (${framework}) has a vague description: "${value}"`,
|
|
108
|
+
file: "Info.plist",
|
|
109
|
+
suggestion: `Provide a specific, user-facing explanation of why ${framework} access is needed`,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ATS - flag NSAllowsArbitraryLoads = true
|
|
115
|
+
const ats = plist.NSAppTransportSecurity;
|
|
116
|
+
if (ats && ats.NSAllowsArbitraryLoads === true) {
|
|
117
|
+
violations.push(makeViolation({
|
|
118
|
+
ruleID: id, guidelineRef: "2.1", severity: Severity.WARNING,
|
|
119
|
+
message: "NSAllowsArbitraryLoads is set to true - disables App Transport Security",
|
|
120
|
+
file: "Info.plist",
|
|
121
|
+
suggestion: "Use NSExceptionDomains for specific domains instead of disabling ATS entirely",
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Minimum deployment target - only flag <iOS 13
|
|
126
|
+
if (typeof plist.MinimumOSVersion === "string") {
|
|
127
|
+
const major = parseInt(plist.MinimumOSVersion.split(".")[0], 10);
|
|
128
|
+
if (Number.isFinite(major) && major < 13) {
|
|
129
|
+
violations.push(makeViolation({
|
|
130
|
+
ruleID: id, guidelineRef: "2.1", severity: Severity.WARNING,
|
|
131
|
+
message: `MinimumOSVersion is ${plist.MinimumOSVersion} - iOS 13+ is recommended for modern API support`,
|
|
132
|
+
file: "Info.plist",
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// UIRequiredDeviceCapabilities (info - not always required)
|
|
138
|
+
if (plist.UIRequiredDeviceCapabilities == null) {
|
|
139
|
+
violations.push(makeViolation({
|
|
140
|
+
ruleID: id, guidelineRef: "2.1", severity: Severity.INFO,
|
|
141
|
+
message: "UIRequiredDeviceCapabilities not set",
|
|
142
|
+
file: "Info.plist",
|
|
143
|
+
suggestion: "Consider specifying required device capabilities",
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Launch storyboard or UIApplicationSceneManifest (modern apps need at least one)
|
|
148
|
+
if (plist.UILaunchStoryboardName == null && plist.UIApplicationSceneManifest == null) {
|
|
149
|
+
violations.push(makeViolation({
|
|
150
|
+
ruleID: id, guidelineRef: "2.1", severity: Severity.WARNING,
|
|
151
|
+
message: "No UILaunchStoryboardName or UIApplicationSceneManifest found",
|
|
152
|
+
file: "Info.plist",
|
|
153
|
+
suggestion: "A launch storyboard is required for all modern iOS apps",
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return violations;
|
|
158
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipv6-compliance - Apple Guideline 2.5.5 (apps must work on IPv6-only networks).
|
|
3
|
+
* Direct port of IPv6ComplianceRule.swift.
|
|
4
|
+
*
|
|
5
|
+
* Scans the executable's printable strings for:
|
|
6
|
+
* - Hardcoded IPv4 literals (filtered to drop version strings + loopback)
|
|
7
|
+
* - Legacy networking APIs that bypass system DNS (gethostbyname, etc.)
|
|
8
|
+
* - Raw socket APIs that don't auto-handle IPv6
|
|
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 = "ipv6-compliance";
|
|
16
|
+
export const name = "IPv6 Compliance";
|
|
17
|
+
export const description = "Scans binary for hardcoded IPv4 addresses and non-IPv6-compatible networking patterns";
|
|
18
|
+
|
|
19
|
+
const LEGACY_NETWORK_APIS = [
|
|
20
|
+
"inet_addr", "inet_aton", "inet_ntoa",
|
|
21
|
+
"gethostbyname", "gethostbyaddr",
|
|
22
|
+
"getaddrinfo",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const RAW_SOCKET_APIS = [
|
|
26
|
+
"SCNetworkReachabilityCreateWithAddress",
|
|
27
|
+
"CFSocketCreate",
|
|
28
|
+
"CFStreamCreatePairWithSocketToHost",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const IPV4_REGEX = /\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g;
|
|
32
|
+
|
|
33
|
+
function isRealIP(ip) {
|
|
34
|
+
const octets = ip.split(".").map((o) => parseInt(o, 10));
|
|
35
|
+
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) return false;
|
|
36
|
+
if (ip === "0.0.0.0" || ip === "127.0.0.1") return false;
|
|
37
|
+
// Skip version-like strings (all octets < 20 is almost certainly "1.0.0.0", "2.3.0.0", etc.)
|
|
38
|
+
if (octets.every((o) => o < 20)) return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function check(ctx) {
|
|
43
|
+
if (!ctx.executablePath || !existsSync(ctx.executablePath)) return [];
|
|
44
|
+
|
|
45
|
+
let stringsOutput = "";
|
|
46
|
+
try {
|
|
47
|
+
stringsOutput = execSync(`/usr/bin/strings "${ctx.executablePath}"`, {
|
|
48
|
+
encoding: "utf-8",
|
|
49
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const violations = [];
|
|
56
|
+
|
|
57
|
+
// 1. Hardcoded IPv4 addresses
|
|
58
|
+
const ipMatches = new Set();
|
|
59
|
+
let m;
|
|
60
|
+
while ((m = IPV4_REGEX.exec(stringsOutput)) !== null) {
|
|
61
|
+
if (isRealIP(m[1])) ipMatches.add(m[1]);
|
|
62
|
+
}
|
|
63
|
+
if (ipMatches.size > 0) {
|
|
64
|
+
const sample = [...ipMatches].slice(0, 5).join(", ");
|
|
65
|
+
violations.push(makeViolation({
|
|
66
|
+
ruleID: id,
|
|
67
|
+
guidelineRef: "2.5.5",
|
|
68
|
+
severity: Severity.WARNING,
|
|
69
|
+
message: `Found ${ipMatches.size} hardcoded IPv4 address(es): ${sample}`,
|
|
70
|
+
suggestion: "Use hostnames instead of hardcoded IP addresses to ensure IPv6 compatibility",
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. Legacy networking APIs
|
|
75
|
+
for (const api of LEGACY_NETWORK_APIS) {
|
|
76
|
+
if (stringsOutput.includes(api)) {
|
|
77
|
+
violations.push(makeViolation({
|
|
78
|
+
ruleID: id,
|
|
79
|
+
guidelineRef: "2.5.5",
|
|
80
|
+
severity: Severity.INFO,
|
|
81
|
+
message: `Binary references legacy networking API: ${api}`,
|
|
82
|
+
suggestion: "Use URLSession or NWConnection for IPv6-compatible networking",
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 3. Raw socket APIs
|
|
88
|
+
for (const api of RAW_SOCKET_APIS) {
|
|
89
|
+
if (stringsOutput.includes(api)) {
|
|
90
|
+
violations.push(makeViolation({
|
|
91
|
+
ruleID: id,
|
|
92
|
+
guidelineRef: "2.5.5",
|
|
93
|
+
severity: Severity.WARNING,
|
|
94
|
+
message: `Binary uses low-level socket API: ${api} - may not work on IPv6-only networks`,
|
|
95
|
+
suggestion: "Use NWConnection or URLSession which handle IPv6 automatically",
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return violations;
|
|
101
|
+
}
|