@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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* binary-size - Validates app bundle size against App Store limits.
|
|
3
|
+
* Direct port of BinarySizeRule.swift.
|
|
4
|
+
*
|
|
5
|
+
* Thresholds:
|
|
6
|
+
* error: > 4 GB - App Store hard limit
|
|
7
|
+
* warning: > 500 MB - pre-thinning, may want App Thinning / ODR
|
|
8
|
+
* info: thinned > 200 MB - cellular download warning
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { directorySize } from "../context.js";
|
|
12
|
+
import { Severity, makeViolation } from "../models.js";
|
|
13
|
+
|
|
14
|
+
export const id = "binary-size";
|
|
15
|
+
export const name = "Binary Size";
|
|
16
|
+
export const description = "Checks .app bundle size against App Store size limits and thresholds";
|
|
17
|
+
|
|
18
|
+
const HARD_LIMIT_BYTES = 4 * 1024 * 1024 * 1024;
|
|
19
|
+
const CELLULAR_LIMIT_BYTES = 200 * 1024 * 1024;
|
|
20
|
+
const THINNING_RATIO = 0.65;
|
|
21
|
+
const DEFAULT_WARNING_THRESHOLD_MB = 500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} ctx - ArchiveContext from context.js
|
|
25
|
+
* @param {object} [opts]
|
|
26
|
+
* @param {number} [opts.warningThresholdMB=500]
|
|
27
|
+
* @returns {Array<object>} violations
|
|
28
|
+
*/
|
|
29
|
+
export function check(ctx, opts = {}) {
|
|
30
|
+
const warningThresholdBytes = (opts.warningThresholdMB ?? DEFAULT_WARNING_THRESHOLD_MB) * 1024 * 1024;
|
|
31
|
+
const violations = [];
|
|
32
|
+
|
|
33
|
+
const appSize = directorySize(ctx.appPath);
|
|
34
|
+
const appSizeMB = appSize / (1024 * 1024);
|
|
35
|
+
const appSizeFormatted = appSizeMB.toFixed(1);
|
|
36
|
+
|
|
37
|
+
const estimatedThinned = appSize * THINNING_RATIO;
|
|
38
|
+
const estimatedThinnedMB = estimatedThinned / (1024 * 1024);
|
|
39
|
+
const estimatedThinnedFormatted = estimatedThinnedMB.toFixed(0);
|
|
40
|
+
|
|
41
|
+
if (appSize > HARD_LIMIT_BYTES) {
|
|
42
|
+
violations.push(makeViolation({
|
|
43
|
+
ruleID: id,
|
|
44
|
+
severity: Severity.ERROR,
|
|
45
|
+
message: `App bundle size (${appSizeFormatted} MB) exceeds App Store limit of 4 GB`,
|
|
46
|
+
file: ctx.appPath,
|
|
47
|
+
suggestion: "Reduce binary size by removing unused assets or using On-Demand Resources",
|
|
48
|
+
}));
|
|
49
|
+
} else if (appSize > warningThresholdBytes) {
|
|
50
|
+
violations.push(makeViolation({
|
|
51
|
+
ruleID: id,
|
|
52
|
+
severity: Severity.WARNING,
|
|
53
|
+
message: `App bundle size is ${appSizeFormatted} MB (estimated ~${estimatedThinnedFormatted} MB after thinning)`,
|
|
54
|
+
file: ctx.appPath,
|
|
55
|
+
suggestion: "Consider App Thinning, On-Demand Resources, or removing unused assets",
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (estimatedThinned > CELLULAR_LIMIT_BYTES) {
|
|
60
|
+
violations.push(makeViolation({
|
|
61
|
+
ruleID: id,
|
|
62
|
+
severity: Severity.INFO,
|
|
63
|
+
message: `Estimated download size ~${estimatedThinnedFormatted} MB - may trigger cellular download warning (limit: 200 MB)`,
|
|
64
|
+
file: ctx.appPath,
|
|
65
|
+
suggestion: "Users on cellular will see a confirmation prompt before downloading",
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return violations;
|
|
70
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* code-signing - Validates the archive's signing posture for App Store
|
|
3
|
+
* submission. Direct port of CodeSigningRule.swift.
|
|
4
|
+
*
|
|
5
|
+
* ITMS error codes: 90035, 90034, 90046, 90717, 91065
|
|
6
|
+
*
|
|
7
|
+
* Checks:
|
|
8
|
+
* 1. get-task-allow MUST be false (debug=true → ITMS-90046 reject)
|
|
9
|
+
* 2. aps-environment MUST be 'production' if present
|
|
10
|
+
* 3. beta-reports-active warning (TestFlight residue)
|
|
11
|
+
* 4. application-identifier well-formed (TEAMID.bundle.id)
|
|
12
|
+
* 5. Main executable passes `codesign --verify --deep --strict`
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from "child_process";
|
|
16
|
+
import { existsSync } from "fs";
|
|
17
|
+
import { Severity, makeViolation } from "../models.js";
|
|
18
|
+
|
|
19
|
+
export const id = "code-signing";
|
|
20
|
+
export const name = "Code Signing";
|
|
21
|
+
export const description = "Checks entitlements, provisioning, and signing for App Store compliance";
|
|
22
|
+
|
|
23
|
+
function isCodeSigned(executablePath) {
|
|
24
|
+
try {
|
|
25
|
+
execSync(`/usr/bin/codesign --verify --deep --strict "${executablePath}"`, {
|
|
26
|
+
stdio: "ignore",
|
|
27
|
+
});
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function check(ctx) {
|
|
35
|
+
const violations = [];
|
|
36
|
+
const ent = ctx.entitlements;
|
|
37
|
+
|
|
38
|
+
if (!ent) {
|
|
39
|
+
violations.push(makeViolation({
|
|
40
|
+
ruleID: id, guidelineRef: "ITMS-90034", severity: Severity.ERROR,
|
|
41
|
+
message: "No entitlements found - binary may not be properly signed",
|
|
42
|
+
suggestion: "Ensure the archive is signed with a valid distribution certificate",
|
|
43
|
+
}));
|
|
44
|
+
} else {
|
|
45
|
+
// get-task-allow must be false for release
|
|
46
|
+
if (ent["get-task-allow"] === true) {
|
|
47
|
+
violations.push(makeViolation({
|
|
48
|
+
ruleID: id, guidelineRef: "ITMS-90046", severity: Severity.ERROR,
|
|
49
|
+
message: "get-task-allow is true - this is a debug build, not valid for App Store",
|
|
50
|
+
suggestion: "Archive with Release configuration and Apple Distribution certificate",
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Push notification environment
|
|
55
|
+
if (ent["aps-environment"] === "development") {
|
|
56
|
+
violations.push(makeViolation({
|
|
57
|
+
ruleID: id, guidelineRef: "ITMS-90717", severity: Severity.ERROR,
|
|
58
|
+
message: "Push notification environment is 'development' instead of 'production'",
|
|
59
|
+
suggestion: "Use App Store provisioning profile with production push entitlement",
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// beta-reports-active = TestFlight residue
|
|
64
|
+
if (ent["beta-reports-active"] != null) {
|
|
65
|
+
violations.push(makeViolation({
|
|
66
|
+
ruleID: id, guidelineRef: "ITMS-90046", severity: Severity.WARNING,
|
|
67
|
+
message: "beta-reports-active entitlement found - may indicate TestFlight build",
|
|
68
|
+
suggestion: "Verify this is intended for App Store submission",
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// application-identifier shape: TEAMID.bundle.id (must contain at least one ".")
|
|
73
|
+
const appID = ent["application-identifier"];
|
|
74
|
+
if (typeof appID === "string" && !appID.includes(".")) {
|
|
75
|
+
violations.push(makeViolation({
|
|
76
|
+
ruleID: id, guidelineRef: "ITMS-90034", severity: Severity.ERROR,
|
|
77
|
+
message: `Invalid application-identifier format: ${appID}`,
|
|
78
|
+
suggestion: "Format should be TEAMID.com.company.app",
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Verify main binary signature
|
|
84
|
+
if (ctx.executablePath && existsSync(ctx.executablePath)) {
|
|
85
|
+
if (!isCodeSigned(ctx.executablePath)) {
|
|
86
|
+
violations.push(makeViolation({
|
|
87
|
+
ruleID: id, guidelineRef: "ITMS-90035", severity: Severity.ERROR,
|
|
88
|
+
message: "Main executable failed code signature verification",
|
|
89
|
+
suggestion: "Re-archive with a valid Apple Distribution certificate",
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return violations;
|
|
95
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dead-reference - Detects potentially orphaned image / nib resources at the
|
|
3
|
+
* .app root that the binary's printable strings never reference.
|
|
4
|
+
* Direct port of DeadReferenceRule.swift.
|
|
5
|
+
*
|
|
6
|
+
* The check is intentionally conservative - false positives are possible
|
|
7
|
+
* (resource loaded via dynamically-built name, runtime localization, etc.).
|
|
8
|
+
* Severity is .warning (images) / .info (empty nibs); never .error.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "child_process";
|
|
12
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
13
|
+
import { join, extname, parse as parsePath } from "path";
|
|
14
|
+
import { Severity, makeViolation } from "../models.js";
|
|
15
|
+
|
|
16
|
+
export const id = "dead-reference";
|
|
17
|
+
export const name = "Dead Reference Detection";
|
|
18
|
+
export const description = "Finds potentially unused resources (images, JSON, nibs) in the app bundle";
|
|
19
|
+
|
|
20
|
+
const CHECKABLE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "pdf", "svg"]);
|
|
21
|
+
|
|
22
|
+
const SYSTEM_PREFIXES = [
|
|
23
|
+
"AppIcon", "LaunchImage", "Default",
|
|
24
|
+
"Assets.car", "Info.plist", "PkgInfo",
|
|
25
|
+
"embedded.mobileprovision", "PrivacyInfo",
|
|
26
|
+
"CodeResources", "ResourceRules",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/** Return all printable strings in a binary as a Set. */
|
|
30
|
+
function binaryStrings(executablePath) {
|
|
31
|
+
try {
|
|
32
|
+
const out = execSync(`/usr/bin/strings "${executablePath}"`, {
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
35
|
+
});
|
|
36
|
+
return new Set(out.split("\n").filter((s) => s.length > 0));
|
|
37
|
+
} catch {
|
|
38
|
+
return new Set();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function check(ctx) {
|
|
43
|
+
const violations = [];
|
|
44
|
+
if (!ctx.executablePath || !existsSync(ctx.executablePath)) return violations;
|
|
45
|
+
|
|
46
|
+
// Skip > 500 MB binaries (string scan would dominate audit runtime).
|
|
47
|
+
let execStat;
|
|
48
|
+
try {
|
|
49
|
+
execStat = statSync(ctx.executablePath);
|
|
50
|
+
} catch {
|
|
51
|
+
return violations;
|
|
52
|
+
}
|
|
53
|
+
if (execStat.size > 500 * 1024 * 1024) return violations;
|
|
54
|
+
|
|
55
|
+
const strings = binaryStrings(ctx.executablePath);
|
|
56
|
+
|
|
57
|
+
// Enumerate root-level files in the .app
|
|
58
|
+
let entries = [];
|
|
59
|
+
try {
|
|
60
|
+
entries = readdirSync(ctx.appPath);
|
|
61
|
+
} catch {
|
|
62
|
+
return violations;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Unreferenced images
|
|
66
|
+
const unreferenced = [];
|
|
67
|
+
for (const file of entries) {
|
|
68
|
+
const ext = extname(file).slice(1).toLowerCase();
|
|
69
|
+
if (!CHECKABLE_EXTENSIONS.has(ext)) continue;
|
|
70
|
+
const name = parsePath(file).name;
|
|
71
|
+
if (SYSTEM_PREFIXES.some((p) => name.startsWith(p))) continue;
|
|
72
|
+
const stripped = name
|
|
73
|
+
.replace("@3x", "")
|
|
74
|
+
.replace("@2x", "")
|
|
75
|
+
.replace("~ipad", "")
|
|
76
|
+
.replace("~iphone", "");
|
|
77
|
+
if (!strings.has(stripped) && !strings.has(name) && !strings.has(file)) {
|
|
78
|
+
unreferenced.push(file);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Dead nib files / empty storyboardc directories
|
|
83
|
+
const deadNibs = [];
|
|
84
|
+
for (const file of entries) {
|
|
85
|
+
const ext = extname(file).slice(1).toLowerCase();
|
|
86
|
+
const full = join(ctx.appPath, file);
|
|
87
|
+
if (ext === "nib") {
|
|
88
|
+
let st;
|
|
89
|
+
try {
|
|
90
|
+
st = statSync(full);
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (st.isFile() && st.size < 100) deadNibs.push(file);
|
|
95
|
+
} else if (ext === "storyboardc") {
|
|
96
|
+
let nested = [];
|
|
97
|
+
try {
|
|
98
|
+
nested = readdirSync(full);
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const hasNib = nested.some((n) => extname(n).slice(1).toLowerCase() === "nib");
|
|
103
|
+
if (!hasNib) deadNibs.push(file);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (unreferenced.length > 0) {
|
|
108
|
+
const sample = unreferenced.slice(0, 10).join(", ");
|
|
109
|
+
const extra = unreferenced.length > 10 ? ` and ${unreferenced.length - 10} more` : "";
|
|
110
|
+
violations.push(makeViolation({
|
|
111
|
+
ruleID: id,
|
|
112
|
+
severity: Severity.WARNING,
|
|
113
|
+
message: `${unreferenced.length} potentially unreferenced image(s): ${sample}${extra}`,
|
|
114
|
+
file: ctx.appPath,
|
|
115
|
+
suggestion: "Verify these images are used. Remove unused assets to reduce app size",
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (deadNibs.length > 0) {
|
|
120
|
+
const sample = deadNibs.slice(0, 5).join(", ");
|
|
121
|
+
violations.push(makeViolation({
|
|
122
|
+
ruleID: id,
|
|
123
|
+
severity: Severity.INFO,
|
|
124
|
+
message: `${deadNibs.length} empty/tiny nib file(s): ${sample}`,
|
|
125
|
+
file: ctx.appPath,
|
|
126
|
+
suggestion: "These may be dead storyboard references - verify they're still needed",
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return violations;
|
|
131
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* debug-tool-leak - Detects 40+ known debug/development tools (Netfox, FLEX,
|
|
3
|
+
* CocoaDebug, Wormholy, DoraemonKit, Lookin, Reveal, Pulse, Hyperion, etc.)
|
|
4
|
+
* plus 30+ generic patterns for unknown tools that have leaked into a
|
|
5
|
+
* release archive.
|
|
6
|
+
* Direct port of DebugToolLeakRule.swift.
|
|
7
|
+
*
|
|
8
|
+
* Apple Guidelines: 2.5.1 (Software Requirements), 2.5.2 (Self-Contained).
|
|
9
|
+
*
|
|
10
|
+
* 5 detection strategies:
|
|
11
|
+
* 1. Embedded framework name match
|
|
12
|
+
* 2. Binary symbol scan (strings | grep -E)
|
|
13
|
+
* 3. Info.plist URL scheme
|
|
14
|
+
* 4. NSAllowsLocalNetworking flag (often a debug-tool tell)
|
|
15
|
+
* 5. Generic name-pattern catch-all
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execSync } from "child_process";
|
|
19
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
20
|
+
import { dirname, basename, join } from "path";
|
|
21
|
+
import { fileURLToPath } from "url";
|
|
22
|
+
import { Severity, makeViolation } from "../models.js";
|
|
23
|
+
|
|
24
|
+
export const id = "debug-tool-leak";
|
|
25
|
+
export const name = "Debug Tool Leak Detection";
|
|
26
|
+
export const description = "Detects debug/development tools (Netfox, FLEX, CocoaDebug, etc.) that should not ship in App Store builds";
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const BLOCKLIST_PATH = join(__dirname, "../data/debug-tools-blocklist.json");
|
|
30
|
+
|
|
31
|
+
let BLOCKLIST = null;
|
|
32
|
+
function loadBlocklist() {
|
|
33
|
+
if (BLOCKLIST !== null) return BLOCKLIST;
|
|
34
|
+
try {
|
|
35
|
+
BLOCKLIST = JSON.parse(readFileSync(BLOCKLIST_PATH, "utf-8"));
|
|
36
|
+
} catch {
|
|
37
|
+
BLOCKLIST = { frameworks: [], symbols: [], urlSchemes: [], genericPatterns: [] };
|
|
38
|
+
}
|
|
39
|
+
return BLOCKLIST;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function escapeForGrep(p) {
|
|
43
|
+
return p.replace(/\(/g, "\\(").replace(/\)/g, "\\)").replace(/\./g, "\\.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function runStringsGrep(executablePath, patterns) {
|
|
47
|
+
if (patterns.length === 0) return "";
|
|
48
|
+
const grepPattern = patterns.map(escapeForGrep).join("|");
|
|
49
|
+
try {
|
|
50
|
+
return execSync(
|
|
51
|
+
`/usr/bin/strings "${executablePath}" | grep -E '${grepPattern}' 2>/dev/null | head -200`,
|
|
52
|
+
{ encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
|
|
53
|
+
);
|
|
54
|
+
} catch {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function checkEmbeddedFrameworks(ctx, detected, list) {
|
|
60
|
+
const violations = [];
|
|
61
|
+
for (const fwPath of ctx.embeddedFrameworks ?? []) {
|
|
62
|
+
const fwName = basename(fwPath).replace(/\.framework$/, "").replace(/\.xcframework$/, "");
|
|
63
|
+
for (const fw of list.frameworks) {
|
|
64
|
+
if (!fwName.toLowerCase().includes(fw.name.toLowerCase())) continue;
|
|
65
|
+
if (detected.has(fw.tool)) continue;
|
|
66
|
+
detected.add(fw.tool);
|
|
67
|
+
violations.push(makeViolation({
|
|
68
|
+
ruleID: id,
|
|
69
|
+
guidelineRef: "2.5.1",
|
|
70
|
+
severity: fw.category.includes("Logger") ? Severity.WARNING : Severity.ERROR,
|
|
71
|
+
message: `Debug tool '${fw.tool}' (${fw.category}) is embedded in the archive`,
|
|
72
|
+
file: fwPath,
|
|
73
|
+
suggestion: `Remove ${fw.tool} from the Release build configuration. Use #if DEBUG to conditionally include debug tools`,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return violations;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkBinarySymbols(ctx, detected, list) {
|
|
81
|
+
const violations = [];
|
|
82
|
+
if (!ctx.executablePath || !existsSync(ctx.executablePath)) return violations;
|
|
83
|
+
let st;
|
|
84
|
+
try {
|
|
85
|
+
st = statSync(ctx.executablePath);
|
|
86
|
+
} catch {
|
|
87
|
+
return violations;
|
|
88
|
+
}
|
|
89
|
+
if (st.size > 500 * 1024 * 1024) return violations;
|
|
90
|
+
|
|
91
|
+
const output = runStringsGrep(ctx.executablePath, list.symbols.map((s) => s.symbol));
|
|
92
|
+
for (const sym of list.symbols) {
|
|
93
|
+
if (output.includes(sym.symbol) && !detected.has(sym.tool)) {
|
|
94
|
+
detected.add(sym.tool);
|
|
95
|
+
violations.push(makeViolation({
|
|
96
|
+
ruleID: id,
|
|
97
|
+
guidelineRef: "2.5.1",
|
|
98
|
+
severity: Severity.ERROR,
|
|
99
|
+
message: `Debug tool '${sym.tool}' symbols found in binary (detected: ${sym.symbol})`,
|
|
100
|
+
suggestion: `Ensure ${sym.tool} is excluded from Release builds. Check build phases and #if DEBUG guards`,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return violations;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function checkURLSchemes(ctx, detected, list) {
|
|
108
|
+
const violations = [];
|
|
109
|
+
const urlTypes = ctx.infoPlist?.CFBundleURLTypes;
|
|
110
|
+
if (!Array.isArray(urlTypes)) return violations;
|
|
111
|
+
const registered = urlTypes.flatMap((t) =>
|
|
112
|
+
Array.isArray(t?.CFBundleURLSchemes) ? t.CFBundleURLSchemes : []
|
|
113
|
+
);
|
|
114
|
+
for (const debug of list.urlSchemes) {
|
|
115
|
+
const match = registered.some(
|
|
116
|
+
(s) => typeof s === "string" && s.toLowerCase() === debug.scheme.toLowerCase()
|
|
117
|
+
);
|
|
118
|
+
if (!match || detected.has(debug.tool)) continue;
|
|
119
|
+
detected.add(debug.tool);
|
|
120
|
+
violations.push(makeViolation({
|
|
121
|
+
ruleID: id,
|
|
122
|
+
guidelineRef: "2.5.1",
|
|
123
|
+
severity: Severity.ERROR,
|
|
124
|
+
message: `Debug URL scheme '${debug.scheme}://' registered for ${debug.tool}`,
|
|
125
|
+
file: "Info.plist",
|
|
126
|
+
suggestion: `Remove the '${debug.scheme}' URL scheme from CFBundleURLTypes in Release builds`,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
return violations;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function checkDebugFlags(ctx) {
|
|
133
|
+
const violations = [];
|
|
134
|
+
const ats = ctx.infoPlist?.NSAppTransportSecurity;
|
|
135
|
+
if (ats?.NSAllowsLocalNetworking === true) {
|
|
136
|
+
violations.push(makeViolation({
|
|
137
|
+
ruleID: id,
|
|
138
|
+
guidelineRef: "2.5.1",
|
|
139
|
+
severity: Severity.INFO,
|
|
140
|
+
message: "NSAllowsLocalNetworking is enabled - commonly used by debug network tools",
|
|
141
|
+
file: "Info.plist",
|
|
142
|
+
suggestion: "Verify this is required for your app's functionality, not just for debug tools",
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
return violations;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkGenericPatterns(ctx, detected, list) {
|
|
149
|
+
const violations = [];
|
|
150
|
+
if (!ctx.executablePath || !existsSync(ctx.executablePath)) return violations;
|
|
151
|
+
let st;
|
|
152
|
+
try {
|
|
153
|
+
st = statSync(ctx.executablePath);
|
|
154
|
+
} catch {
|
|
155
|
+
return violations;
|
|
156
|
+
}
|
|
157
|
+
if (st.size > 500 * 1024 * 1024) return violations;
|
|
158
|
+
|
|
159
|
+
const output = runStringsGrep(ctx.executablePath, list.genericPatterns.map((p) => p.pattern));
|
|
160
|
+
for (const p of list.genericPatterns) {
|
|
161
|
+
if (output.includes(p.pattern) && !detected.has(p.category)) {
|
|
162
|
+
detected.add(p.category);
|
|
163
|
+
violations.push(makeViolation({
|
|
164
|
+
ruleID: id,
|
|
165
|
+
guidelineRef: "2.5.1",
|
|
166
|
+
severity: Severity.WARNING,
|
|
167
|
+
message: `Potential debug tool pattern detected: ${p.category} (matched: '${p.pattern}')`,
|
|
168
|
+
suggestion: "Verify this is not a debug/development tool. If it is, exclude from Release builds with #if DEBUG",
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return violations;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function check(ctx) {
|
|
176
|
+
const list = loadBlocklist();
|
|
177
|
+
const detected = new Set();
|
|
178
|
+
return [
|
|
179
|
+
...checkEmbeddedFrameworks(ctx, detected, list),
|
|
180
|
+
...checkBinarySymbols(ctx, detected, list),
|
|
181
|
+
...checkURLSchemes(ctx, detected, list),
|
|
182
|
+
...checkDebugFlags(ctx),
|
|
183
|
+
...checkGenericPatterns(ctx, detected, list),
|
|
184
|
+
];
|
|
185
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* duplicate-resource - Detects duplicate resource files across the .app and
|
|
3
|
+
* embedded frameworks/extensions. Direct port of DuplicateResourceRule.swift.
|
|
4
|
+
*
|
|
5
|
+
* Why it matters: duplicates inflate download size and can lead to
|
|
6
|
+
* unpredictable runtime behaviour (which copy wins?).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
10
|
+
import { join, basename, extname } from "path";
|
|
11
|
+
import { Severity, makeViolation } from "../models.js";
|
|
12
|
+
|
|
13
|
+
export const id = "duplicate-resource";
|
|
14
|
+
export const name = "Duplicate Resource Detection";
|
|
15
|
+
export const description = "Finds duplicate files across app bundle and embedded frameworks";
|
|
16
|
+
|
|
17
|
+
const RESOURCE_EXTENSIONS = new Set([
|
|
18
|
+
"json", "plist", "png", "jpg", "jpeg", "gif", "pdf", "svg",
|
|
19
|
+
"strings", "stringsdict", "storyboardc", "nib", "xib",
|
|
20
|
+
"car", "ttf", "otf", "wav", "mp3", "mp4", "m4a",
|
|
21
|
+
"js", "html", "css", "xml", "csv", "txt", "dat",
|
|
22
|
+
"mlmodel", "mlmodelc", "xcmappingmodel",
|
|
23
|
+
"mom", "momd", "sqlite",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Walk a directory tree, yielding relative paths to every regular file. */
|
|
27
|
+
function* walk(root, prefix = "") {
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(root);
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (const e of entries) {
|
|
35
|
+
const full = join(root, e);
|
|
36
|
+
const rel = prefix ? `${prefix}/${e}` : e;
|
|
37
|
+
let st;
|
|
38
|
+
try {
|
|
39
|
+
st = statSync(full);
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (st.isDirectory()) {
|
|
44
|
+
yield* walk(full, rel);
|
|
45
|
+
} else if (st.isFile()) {
|
|
46
|
+
yield { rel, full, size: st.size };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function locationFor(relativePath) {
|
|
52
|
+
if (relativePath.startsWith("Frameworks/")) {
|
|
53
|
+
const parts = relativePath.split("/");
|
|
54
|
+
return parts.length >= 2 ? parts[1] : "Frameworks";
|
|
55
|
+
}
|
|
56
|
+
if (relativePath.startsWith("PlugIns/")) {
|
|
57
|
+
const parts = relativePath.split("/");
|
|
58
|
+
return parts.length >= 2 ? parts[1] : "PlugIns";
|
|
59
|
+
}
|
|
60
|
+
return "App Bundle";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function check(ctx) {
|
|
64
|
+
const violations = [];
|
|
65
|
+
if (!ctx.appPath || !existsSync(ctx.appPath)) return violations;
|
|
66
|
+
|
|
67
|
+
// filename → { locations: Set<string>, size: number (any one copy) }
|
|
68
|
+
const fileMap = new Map();
|
|
69
|
+
|
|
70
|
+
for (const { rel, size } of walk(ctx.appPath)) {
|
|
71
|
+
// Skip compiled asset catalog internals
|
|
72
|
+
if (rel.includes(".car/")) continue;
|
|
73
|
+
const file = basename(rel);
|
|
74
|
+
const ext = extname(file).slice(1).toLowerCase();
|
|
75
|
+
if (!RESOURCE_EXTENSIONS.has(ext)) continue;
|
|
76
|
+
|
|
77
|
+
const loc = locationFor(rel);
|
|
78
|
+
if (!fileMap.has(file)) fileMap.set(file, { locations: new Set([loc]), size });
|
|
79
|
+
else {
|
|
80
|
+
const entry = fileMap.get(file);
|
|
81
|
+
entry.locations.add(loc);
|
|
82
|
+
// Keep largest size as worst-case waste estimate.
|
|
83
|
+
if (size > entry.size) entry.size = size;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const duplicates = [...fileMap.entries()]
|
|
88
|
+
.filter(([, entry]) => entry.locations.size > 1)
|
|
89
|
+
.sort(([, a], [, b]) => b.locations.size - a.locations.size);
|
|
90
|
+
|
|
91
|
+
if (duplicates.length === 0) return violations;
|
|
92
|
+
|
|
93
|
+
// Report the top 10 individually.
|
|
94
|
+
for (const [filename, entry] of duplicates.slice(0, 10)) {
|
|
95
|
+
const locs = [...entry.locations];
|
|
96
|
+
violations.push(makeViolation({
|
|
97
|
+
ruleID: id,
|
|
98
|
+
severity: Severity.WARNING,
|
|
99
|
+
message: `'${filename}' found in ${locs.length} locations: ${locs.join(", ")}`,
|
|
100
|
+
file: ctx.appPath,
|
|
101
|
+
suggestion: "Remove duplicate or ensure only one copy is bundled",
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (duplicates.length > 10) {
|
|
106
|
+
violations.push(makeViolation({
|
|
107
|
+
ruleID: id,
|
|
108
|
+
severity: Severity.INFO,
|
|
109
|
+
message: `${duplicates.length - 10} more duplicate resources found`,
|
|
110
|
+
suggestion: "Run with the --format=json output to see the full list",
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Estimated wasted bytes: (copies - 1) * size, summed across all dup files.
|
|
115
|
+
let wasted = 0;
|
|
116
|
+
for (const [, entry] of duplicates) {
|
|
117
|
+
wasted += (entry.locations.size - 1) * entry.size;
|
|
118
|
+
}
|
|
119
|
+
if (wasted > 0) {
|
|
120
|
+
const wastedMB = (wasted / (1024 * 1024)).toFixed(1);
|
|
121
|
+
violations.push(makeViolation({
|
|
122
|
+
ruleID: id,
|
|
123
|
+
severity: Severity.INFO,
|
|
124
|
+
message: `Total ${duplicates.length} duplicate resources - estimated ~${wastedMB} MB wasted`,
|
|
125
|
+
file: ctx.appPath,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return violations;
|
|
130
|
+
}
|