ios-app-review-plugin 1.0.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/.claude/settings.local.json +42 -0
- package/.github/actions/ios-review/action.yml +106 -0
- package/.github/workflows/ci.yml +103 -0
- package/.github/workflows/publish.yml +57 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +175 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bitrise/step.sh +128 -0
- package/bitrise/step.yml +101 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzers/asc-iap.d.ts.map +1 -0
- package/dist/analyzers/asc-metadata.d.ts.map +1 -0
- package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
- package/dist/analyzers/asc-version.d.ts.map +1 -0
- package/dist/analyzers/code-scanner.d.ts.map +1 -0
- package/dist/analyzers/deprecated-api.d.ts.map +1 -0
- package/dist/analyzers/entitlements.d.ts.map +1 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/info-plist.d.ts.map +1 -0
- package/dist/analyzers/privacy.d.ts.map +1 -0
- package/dist/analyzers/private-api.d.ts.map +1 -0
- package/dist/analyzers/security.d.ts.map +1 -0
- package/dist/analyzers/ui-ux.d.ts.map +1 -0
- package/dist/asc/auth.d.ts.map +1 -0
- package/dist/asc/client.d.ts.map +1 -0
- package/dist/asc/endpoints/apps.d.ts.map +1 -0
- package/dist/asc/endpoints/iap.d.ts.map +1 -0
- package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
- package/dist/asc/endpoints/versions.d.ts.map +1 -0
- package/dist/asc/errors.d.ts.map +1 -0
- package/dist/asc/index.d.ts.map +1 -0
- package/dist/asc/types.d.ts.map +1 -0
- package/dist/badge/generator.d.ts.map +1 -0
- package/dist/badge/index.d.ts.map +1 -0
- package/dist/badge/types.d.ts.map +1 -0
- package/dist/cache/file-cache.d.ts.map +1 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/scan.d.ts.map +1 -0
- package/dist/cli/commands/version.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/git/diff.d.ts.map +1 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/types.d.ts.map +1 -0
- package/dist/guidelines/database.d.ts.map +1 -0
- package/dist/guidelines/index.d.ts.map +1 -0
- package/dist/guidelines/matcher.d.ts.map +1 -0
- package/dist/guidelines/types.d.ts.map +1 -0
- package/dist/history/comparator.d.ts.map +1 -0
- package/dist/history/index.d.ts.map +1 -0
- package/dist/history/store.d.ts.map +1 -0
- package/dist/history/types.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +994 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/plist.d.ts.map +1 -0
- package/dist/parsers/xcodeproj.d.ts.map +1 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/reporter.d.ts.map +1 -0
- package/dist/progress/types.d.ts.map +1 -0
- package/dist/reports/html.d.ts.map +1 -0
- package/dist/reports/index.d.ts.map +1 -0
- package/dist/reports/json.d.ts.map +1 -0
- package/dist/reports/markdown.d.ts.map +1 -0
- package/dist/reports/types.d.ts.map +1 -0
- package/dist/rules/engine.d.ts.map +1 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/loader.d.ts.map +1 -0
- package/dist/rules/types.d.ts.map +1 -0
- package/dist/types/index.d.ts.map +1 -0
- package/docs/ANALYZERS.md +237 -0
- package/docs/API.md +308 -0
- package/docs/BADGES.md +130 -0
- package/docs/CI_CD.md +283 -0
- package/docs/CLI.md +140 -0
- package/docs/REPORTS.md +212 -0
- package/docs/ROADMAP.md +267 -0
- package/docs/RULES.md +182 -0
- package/docs/SECURITY.md +89 -0
- package/docs/TROUBLESHOOTING.md +227 -0
- package/docs/tutorials/ASC_SETUP.md +188 -0
- package/docs/tutorials/CI_INTEGRATION.md +292 -0
- package/docs/tutorials/CUSTOM_RULES.md +291 -0
- package/docs/tutorials/GETTING_STARTED.md +226 -0
- package/docs/video-scripts/01-introduction.md +106 -0
- package/docs/video-scripts/02-cli-usage.md +120 -0
- package/docs/video-scripts/03-ci-integration.md +198 -0
- package/eslint.config.js +33 -0
- package/examples/.ios-review-rules.json +82 -0
- package/examples/bitrise-workflow.yml +129 -0
- package/examples/fastlane-lane.rb +71 -0
- package/examples/github-action.yml +147 -0
- package/fastlane/Fastfile.example +114 -0
- package/fastlane/README.md +99 -0
- package/jest.config.js +36 -0
- package/package.json +65 -0
- package/scripts/benchmark.ts +112 -0
- package/scripts/debug-parser.ts +37 -0
- package/scripts/debug-pbxproj.ts +36 -0
- package/scripts/debug-specific.ts +47 -0
- package/scripts/test-analyze.ts +67 -0
- package/scripts/xcode-cloud-review.sh +167 -0
- package/src/analyzer.ts +227 -0
- package/src/analyzers/asc-iap.ts +300 -0
- package/src/analyzers/asc-metadata.ts +326 -0
- package/src/analyzers/asc-screenshots.ts +310 -0
- package/src/analyzers/asc-version.ts +368 -0
- package/src/analyzers/code-scanner.ts +408 -0
- package/src/analyzers/deprecated-api.ts +390 -0
- package/src/analyzers/entitlements.ts +345 -0
- package/src/analyzers/index.ts +12 -0
- package/src/analyzers/info-plist.ts +409 -0
- package/src/analyzers/privacy.ts +376 -0
- package/src/analyzers/private-api.ts +377 -0
- package/src/analyzers/security.ts +327 -0
- package/src/analyzers/ui-ux.ts +509 -0
- package/src/asc/auth.ts +204 -0
- package/src/asc/client.ts +258 -0
- package/src/asc/endpoints/apps.ts +115 -0
- package/src/asc/endpoints/iap.ts +171 -0
- package/src/asc/endpoints/screenshots.ts +164 -0
- package/src/asc/endpoints/versions.ts +174 -0
- package/src/asc/errors.ts +109 -0
- package/src/asc/index.ts +108 -0
- package/src/asc/types.ts +369 -0
- package/src/badge/generator.ts +48 -0
- package/src/badge/index.ts +2 -0
- package/src/badge/types.ts +5 -0
- package/src/cache/file-cache.ts +75 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/types.ts +10 -0
- package/src/cli/commands/help.ts +41 -0
- package/src/cli/commands/scan.ts +44 -0
- package/src/cli/commands/version.ts +12 -0
- package/src/cli/index.ts +92 -0
- package/src/cli/types.ts +17 -0
- package/src/git/diff.ts +21 -0
- package/src/git/index.ts +2 -0
- package/src/git/types.ts +5 -0
- package/src/guidelines/database.ts +344 -0
- package/src/guidelines/index.ts +4 -0
- package/src/guidelines/matcher.ts +84 -0
- package/src/guidelines/types.ts +28 -0
- package/src/history/comparator.ts +114 -0
- package/src/history/index.ts +3 -0
- package/src/history/store.ts +135 -0
- package/src/history/types.ts +40 -0
- package/src/index.ts +1113 -0
- package/src/parsers/index.ts +3 -0
- package/src/parsers/plist.ts +253 -0
- package/src/parsers/xcodeproj.ts +265 -0
- package/src/progress/index.ts +2 -0
- package/src/progress/reporter.ts +65 -0
- package/src/progress/types.ts +9 -0
- package/src/reports/html.ts +322 -0
- package/src/reports/index.ts +20 -0
- package/src/reports/json.ts +92 -0
- package/src/reports/markdown.ts +187 -0
- package/src/reports/types.ts +26 -0
- package/src/rules/engine.ts +121 -0
- package/src/rules/index.ts +3 -0
- package/src/rules/loader.ts +83 -0
- package/src/rules/types.ts +25 -0
- package/src/types/index.ts +247 -0
- package/tests/analyzer.test.ts +142 -0
- package/tests/analyzers/asc-iap.test.ts +228 -0
- package/tests/analyzers/asc-metadata.test.ts +210 -0
- package/tests/analyzers/asc-screenshots.test.ts +135 -0
- package/tests/analyzers/asc-version.test.ts +259 -0
- package/tests/analyzers/code-scanner.test.ts +745 -0
- package/tests/analyzers/deprecated-api.test.ts +286 -0
- package/tests/analyzers/entitlements.test.ts +411 -0
- package/tests/analyzers/info-plist.test.ts +148 -0
- package/tests/analyzers/privacy.test.ts +623 -0
- package/tests/analyzers/private-api.test.ts +255 -0
- package/tests/analyzers/security.test.ts +300 -0
- package/tests/analyzers/ui-ux.test.ts +357 -0
- package/tests/asc/auth.test.ts +189 -0
- package/tests/asc/client.test.ts +207 -0
- package/tests/asc/endpoints.test.ts +1359 -0
- package/tests/badge/generator.test.ts +73 -0
- package/tests/cache/file-cache.test.ts +124 -0
- package/tests/cli/cli-index.test.ts +510 -0
- package/tests/cli/commands.test.ts +67 -0
- package/tests/cli/scan.test.ts +152 -0
- package/tests/git/diff.test.ts +69 -0
- package/tests/guidelines/matcher.test.ts +209 -0
- package/tests/history/comparator.test.ts +272 -0
- package/tests/history/store.test.ts +200 -0
- package/tests/integration/cli.test.ts +95 -0
- package/tests/integration/e2e.test.ts +130 -0
- package/tests/parsers/plist.test.ts +240 -0
- package/tests/parsers/xcodeproj.test.ts +289 -0
- package/tests/progress/reporter.test.ts +117 -0
- package/tests/reports/html.test.ts +176 -0
- package/tests/reports/json.test.ts +235 -0
- package/tests/reports/markdown.test.ts +196 -0
- package/tests/rules/engine.test.ts +229 -0
- package/tests/rules/loader.test.ts +187 -0
- package/tests/setup.ts +15 -0
- package/tsconfig.json +27 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { PrivateAPIAnalyzer } from '../../src/analyzers/private-api.js';
|
|
5
|
+
import type { XcodeProject } from '../../src/types/index.js';
|
|
6
|
+
|
|
7
|
+
describe('PrivateAPIAnalyzer', () => {
|
|
8
|
+
let analyzer: PrivateAPIAnalyzer;
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'private-api-test-'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
analyzer = new PrivateAPIAnalyzer();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const mockProject: XcodeProject = {
|
|
24
|
+
path: '/test/TestApp.xcodeproj',
|
|
25
|
+
name: 'TestApp',
|
|
26
|
+
targets: [
|
|
27
|
+
{
|
|
28
|
+
name: 'TestApp',
|
|
29
|
+
type: 'application',
|
|
30
|
+
bundleIdentifier: 'com.test.app',
|
|
31
|
+
sourceFiles: [],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
configurations: ['Debug', 'Release'],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('scanPath', () => {
|
|
38
|
+
it('should detect NSSelectorFromString with private selectors', async () => {
|
|
39
|
+
const filePath = path.join(tempDir, 'PrivateSelector.swift');
|
|
40
|
+
await fs.writeFile(
|
|
41
|
+
filePath,
|
|
42
|
+
`import UIKit
|
|
43
|
+
|
|
44
|
+
func hackStatusBar() {
|
|
45
|
+
let sel = NSSelectorFromString("_setStatusBarHidden:")
|
|
46
|
+
}
|
|
47
|
+
`
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const result = await analyzer.scanPath(filePath);
|
|
51
|
+
expect(result.issues.some((i) => i.id === 'private-underscore-selector')).toBe(true);
|
|
52
|
+
expect(result.issues[0]?.severity).toBe('error');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should detect NSClassFromString with private classes', async () => {
|
|
56
|
+
const filePath = path.join(tempDir, 'PrivateClass.swift');
|
|
57
|
+
await fs.writeFile(
|
|
58
|
+
filePath,
|
|
59
|
+
`import UIKit
|
|
60
|
+
|
|
61
|
+
func getPrivateClass() {
|
|
62
|
+
let cls = NSClassFromString("_UIStatusBarForegroundView")
|
|
63
|
+
}
|
|
64
|
+
`
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const result = await analyzer.scanPath(filePath);
|
|
68
|
+
expect(result.issues.some((i) => i.id === 'private-class-from-string')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should detect private framework imports in Swift', async () => {
|
|
72
|
+
const filePath = path.join(tempDir, 'PrivateFramework.swift');
|
|
73
|
+
await fs.writeFile(
|
|
74
|
+
filePath,
|
|
75
|
+
`import GraphicsServices
|
|
76
|
+
|
|
77
|
+
func doSomething() {
|
|
78
|
+
// Using private framework
|
|
79
|
+
}
|
|
80
|
+
`
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const result = await analyzer.scanPath(filePath);
|
|
84
|
+
expect(result.issues.some((i) => i.id === 'private-framework-graphicsservices')).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should detect private framework imports in ObjC', async () => {
|
|
88
|
+
const filePath = path.join(tempDir, 'PrivateObjC.m');
|
|
89
|
+
await fs.writeFile(
|
|
90
|
+
filePath,
|
|
91
|
+
`#import <SpringBoardServices/SpringBoardServices.h>
|
|
92
|
+
|
|
93
|
+
- (void)doSomething {
|
|
94
|
+
// Using private framework
|
|
95
|
+
}
|
|
96
|
+
`
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const result = await analyzer.scanPath(filePath);
|
|
100
|
+
expect(result.issues.some((i) => i.id === 'private-framework-springboardservices')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should detect private URL schemes', async () => {
|
|
104
|
+
const filePath = path.join(tempDir, 'URLScheme.swift');
|
|
105
|
+
await fs.writeFile(
|
|
106
|
+
filePath,
|
|
107
|
+
`import UIKit
|
|
108
|
+
|
|
109
|
+
func openCydia() {
|
|
110
|
+
let url = URL(string: "cydia://package/com.example")!
|
|
111
|
+
UIApplication.shared.open(url)
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await analyzer.scanPath(filePath);
|
|
117
|
+
expect(result.issues.some((i) => i.id === 'private-url-scheme')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should detect prefs:// URL scheme', async () => {
|
|
121
|
+
const filePath = path.join(tempDir, 'PrefsURL.swift');
|
|
122
|
+
await fs.writeFile(
|
|
123
|
+
filePath,
|
|
124
|
+
`import UIKit
|
|
125
|
+
|
|
126
|
+
func openSettings() {
|
|
127
|
+
let url = URL(string: "prefs://")!
|
|
128
|
+
UIApplication.shared.open(url)
|
|
129
|
+
}
|
|
130
|
+
`
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const result = await analyzer.scanPath(filePath);
|
|
134
|
+
expect(result.issues.some((i) => i.id === 'private-url-scheme')).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should detect dlopen for private frameworks', async () => {
|
|
138
|
+
const filePath = path.join(tempDir, 'DynamicLoad.m');
|
|
139
|
+
await fs.writeFile(
|
|
140
|
+
filePath,
|
|
141
|
+
`#import <dlfcn.h>
|
|
142
|
+
|
|
143
|
+
void loadPrivateFramework() {
|
|
144
|
+
void *handle = dlopen("/System/Library/PrivateFrameworks/ChatKit.framework/ChatKit", RTLD_NOW);
|
|
145
|
+
}
|
|
146
|
+
`
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const result = await analyzer.scanPath(filePath);
|
|
150
|
+
expect(result.issues.some((i) => i.id === 'private-dlopen')).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should detect IOKit private API usage', async () => {
|
|
154
|
+
const filePath = path.join(tempDir, 'IOKitUsage.m');
|
|
155
|
+
await fs.writeFile(
|
|
156
|
+
filePath,
|
|
157
|
+
`#import <IOKit/IOKitLib.h>
|
|
158
|
+
|
|
159
|
+
void getBatteryInfo() {
|
|
160
|
+
io_service_t service = IOServiceGetMatchingService(kIOMasterPortDefault, matching);
|
|
161
|
+
}
|
|
162
|
+
`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const result = await analyzer.scanPath(filePath);
|
|
166
|
+
expect(result.issues.some((i) => i.id === 'private-iokit')).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should detect sandbox escape attempts', async () => {
|
|
170
|
+
const filePath = path.join(tempDir, 'SandboxEscape.swift');
|
|
171
|
+
await fs.writeFile(
|
|
172
|
+
filePath,
|
|
173
|
+
`import Foundation
|
|
174
|
+
|
|
175
|
+
func checkFiles() {
|
|
176
|
+
let exists = FileManager.default.fileExists(atPath: "/var/mobile/Library/Preferences")
|
|
177
|
+
}
|
|
178
|
+
`
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const result = await analyzer.scanPath(filePath);
|
|
182
|
+
expect(result.issues.some((i) => i.id === 'private-sandbox-escape')).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should detect valueForKey with private properties', async () => {
|
|
186
|
+
const filePath = path.join(tempDir, 'PrivateKVC.swift');
|
|
187
|
+
await fs.writeFile(
|
|
188
|
+
filePath,
|
|
189
|
+
`import UIKit
|
|
190
|
+
|
|
191
|
+
func getPrivateProperty() {
|
|
192
|
+
let view = UIView()
|
|
193
|
+
let val = view.value(forKey: "_contentView")
|
|
194
|
+
}
|
|
195
|
+
`
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const result = await analyzer.scanPath(filePath);
|
|
199
|
+
expect(result.issues.some((i) => i.id === 'private-value-for-key')).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should pass with no private API usage', async () => {
|
|
203
|
+
const filePath = path.join(tempDir, 'CleanCode.swift');
|
|
204
|
+
await fs.writeFile(
|
|
205
|
+
filePath,
|
|
206
|
+
`import UIKit
|
|
207
|
+
|
|
208
|
+
class ViewController: UIViewController {
|
|
209
|
+
override func viewDidLoad() {
|
|
210
|
+
super.viewDidLoad()
|
|
211
|
+
view.backgroundColor = .white
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
`
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const result = await analyzer.scanPath(filePath);
|
|
218
|
+
expect(result.passed).toBe(true);
|
|
219
|
+
expect(result.issues).toHaveLength(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should skip commented-out code', async () => {
|
|
223
|
+
const filePath = path.join(tempDir, 'Commented.swift');
|
|
224
|
+
await fs.writeFile(
|
|
225
|
+
filePath,
|
|
226
|
+
`import UIKit
|
|
227
|
+
|
|
228
|
+
// import GraphicsServices
|
|
229
|
+
// NSSelectorFromString("_setStatusBarHidden:")
|
|
230
|
+
`
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const result = await analyzer.scanPath(filePath);
|
|
234
|
+
expect(result.issues).toHaveLength(0);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('analyze', () => {
|
|
239
|
+
it('should work with project interface', async () => {
|
|
240
|
+
const subDir = path.join(tempDir, 'AnalyzeDir');
|
|
241
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
242
|
+
await fs.writeFile(
|
|
243
|
+
path.join(subDir, 'Clean.swift'),
|
|
244
|
+
`import UIKit\nclass VC: UIViewController {}\n`
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const result = await analyzer.analyze(mockProject, {
|
|
248
|
+
basePath: subDir,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(result.analyzer).toBe('Private API Scanner');
|
|
252
|
+
expect(result.passed).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { SecurityAnalyzer } from '../../src/analyzers/security.js';
|
|
5
|
+
import type { XcodeProject } from '../../src/types/index.js';
|
|
6
|
+
|
|
7
|
+
describe('SecurityAnalyzer', () => {
|
|
8
|
+
let analyzer: SecurityAnalyzer;
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'security-test-'));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
analyzer = new SecurityAnalyzer();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const mockProject: XcodeProject = {
|
|
24
|
+
path: '/test/TestApp.xcodeproj',
|
|
25
|
+
name: 'TestApp',
|
|
26
|
+
targets: [
|
|
27
|
+
{
|
|
28
|
+
name: 'TestApp',
|
|
29
|
+
type: 'application',
|
|
30
|
+
bundleIdentifier: 'com.test.app',
|
|
31
|
+
sourceFiles: [],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
configurations: ['Debug', 'Release'],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('scanPath', () => {
|
|
38
|
+
it('should detect MD5 usage', async () => {
|
|
39
|
+
const filePath = path.join(tempDir, 'Crypto.swift');
|
|
40
|
+
await fs.writeFile(
|
|
41
|
+
filePath,
|
|
42
|
+
`import CommonCrypto
|
|
43
|
+
|
|
44
|
+
func hashPassword(_ password: String) -> String {
|
|
45
|
+
var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
|
|
46
|
+
CC_MD5(password, CC_LONG(password.count), &digest)
|
|
47
|
+
return digest.map { String(format: "%02x", $0) }.joined()
|
|
48
|
+
}
|
|
49
|
+
`
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const result = await analyzer.scanPath(filePath);
|
|
53
|
+
expect(result.issues.some((i) => i.id === 'security-md5')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should detect SHA-1 usage', async () => {
|
|
57
|
+
const filePath = path.join(tempDir, 'Hash.swift');
|
|
58
|
+
await fs.writeFile(
|
|
59
|
+
filePath,
|
|
60
|
+
`import CommonCrypto
|
|
61
|
+
|
|
62
|
+
func hash(_ data: Data) {
|
|
63
|
+
var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
|
|
64
|
+
CC_SHA1(data.bytes, CC_LONG(data.count), &digest)
|
|
65
|
+
}
|
|
66
|
+
`
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const result = await analyzer.scanPath(filePath);
|
|
70
|
+
expect(result.issues.some((i) => i.id === 'security-sha1')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should detect DES encryption', async () => {
|
|
74
|
+
const filePath = path.join(tempDir, 'WeakCrypto.swift');
|
|
75
|
+
await fs.writeFile(
|
|
76
|
+
filePath,
|
|
77
|
+
`import CommonCrypto
|
|
78
|
+
|
|
79
|
+
func encrypt(_ data: Data) {
|
|
80
|
+
let algorithm = kCCAlgorithmDES
|
|
81
|
+
CCCrypt(kCCEncrypt, algorithm, 0, key, kCCKeySizeDES, iv, data, dataLength, buffer, bufferSize, &numBytesEncrypted)
|
|
82
|
+
}
|
|
83
|
+
`
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const result = await analyzer.scanPath(filePath);
|
|
87
|
+
expect(result.issues.some((i) => i.id === 'security-des')).toBe(true);
|
|
88
|
+
expect(result.issues.find((i) => i.id === 'security-des')?.severity).toBe('error');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should detect ECB mode', async () => {
|
|
92
|
+
const filePath = path.join(tempDir, 'ECBMode.swift');
|
|
93
|
+
await fs.writeFile(
|
|
94
|
+
filePath,
|
|
95
|
+
`import CommonCrypto
|
|
96
|
+
|
|
97
|
+
func encrypt(_ data: Data) {
|
|
98
|
+
CCCrypt(kCCEncrypt, kCCAlgorithmAES, kCCOptionECBMode, key, kCCKeySizeAES256, nil, data, dataLength, buffer, bufferSize, &numBytesEncrypted)
|
|
99
|
+
}
|
|
100
|
+
`
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const result = await analyzer.scanPath(filePath);
|
|
104
|
+
expect(result.issues.some((i) => i.id === 'security-ecb-mode')).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should detect sensitive data in UserDefaults', async () => {
|
|
108
|
+
const filePath = path.join(tempDir, 'UserDefaultsSensitive.swift');
|
|
109
|
+
await fs.writeFile(
|
|
110
|
+
filePath,
|
|
111
|
+
`import Foundation
|
|
112
|
+
|
|
113
|
+
func saveCredentials() {
|
|
114
|
+
UserDefaults.standard.set(password, forKey: "password")
|
|
115
|
+
}
|
|
116
|
+
`
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const result = await analyzer.scanPath(filePath);
|
|
120
|
+
const sensitiveIssues = result.issues.filter((i) => i.id.startsWith('security-userdefaults'));
|
|
121
|
+
expect(sensitiveIssues.length).toBeGreaterThan(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should detect sensitive token storage in UserDefaults', async () => {
|
|
125
|
+
const filePath = path.join(tempDir, 'TokenStore.swift');
|
|
126
|
+
await fs.writeFile(
|
|
127
|
+
filePath,
|
|
128
|
+
`import Foundation
|
|
129
|
+
|
|
130
|
+
func saveToken(_ token: String) {
|
|
131
|
+
UserDefaults.standard.set(token, forKey: "authToken")
|
|
132
|
+
}
|
|
133
|
+
`
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const result = await analyzer.scanPath(filePath);
|
|
137
|
+
expect(result.issues.some((i) => i.id === 'security-userdefaults-sensitive-set')).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should detect insecure random number generation', async () => {
|
|
141
|
+
const filePath = path.join(tempDir, 'InsecureRandom.m');
|
|
142
|
+
await fs.writeFile(
|
|
143
|
+
filePath,
|
|
144
|
+
`#import <stdlib.h>
|
|
145
|
+
|
|
146
|
+
- (NSString *)generateToken {
|
|
147
|
+
srand(time(NULL));
|
|
148
|
+
int token = rand();
|
|
149
|
+
return [NSString stringWithFormat:@"%d", token];
|
|
150
|
+
}
|
|
151
|
+
`
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const result = await analyzer.scanPath(filePath);
|
|
155
|
+
expect(result.issues.some((i) => i.id === 'security-insecure-random')).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should detect insecure keychain accessibility', async () => {
|
|
159
|
+
const filePath = path.join(tempDir, 'KeychainInsecure.swift');
|
|
160
|
+
await fs.writeFile(
|
|
161
|
+
filePath,
|
|
162
|
+
`import Security
|
|
163
|
+
|
|
164
|
+
func saveToKeychain(data: Data) {
|
|
165
|
+
let query: [String: Any] = [
|
|
166
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
167
|
+
kSecAttrAccessible as String: kSecAttrAccessibleAlways,
|
|
168
|
+
kSecValueData as String: data,
|
|
169
|
+
]
|
|
170
|
+
SecItemAdd(query as CFDictionary, nil)
|
|
171
|
+
}
|
|
172
|
+
`
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const result = await analyzer.scanPath(filePath);
|
|
176
|
+
expect(result.issues.some((i) => i.id === 'security-keychain-accessible-always')).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should detect hardcoded encryption keys', async () => {
|
|
180
|
+
const filePath = path.join(tempDir, 'HardcodedKey.swift');
|
|
181
|
+
await fs.writeFile(
|
|
182
|
+
filePath,
|
|
183
|
+
`import Foundation
|
|
184
|
+
|
|
185
|
+
class Encryptor {
|
|
186
|
+
let encryptionKey = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"
|
|
187
|
+
|
|
188
|
+
func encrypt(_ data: Data) -> Data {
|
|
189
|
+
// encrypt with key
|
|
190
|
+
return data
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
`
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const result = await analyzer.scanPath(filePath);
|
|
197
|
+
expect(result.issues.some((i) => i.id === 'security-hardcoded-encryption-key')).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should detect logging of sensitive data', async () => {
|
|
201
|
+
const filePath = path.join(tempDir, 'SensitiveLog.swift');
|
|
202
|
+
await fs.writeFile(
|
|
203
|
+
filePath,
|
|
204
|
+
`import Foundation
|
|
205
|
+
|
|
206
|
+
func login(username: String, password: String) {
|
|
207
|
+
print("Login attempt with password: \\(password)")
|
|
208
|
+
}
|
|
209
|
+
`
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const result = await analyzer.scanPath(filePath);
|
|
213
|
+
expect(result.issues.some((i) => i.id === 'security-logging-sensitive')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should pass with secure code', async () => {
|
|
217
|
+
const filePath = path.join(tempDir, 'SecureCode.swift');
|
|
218
|
+
await fs.writeFile(
|
|
219
|
+
filePath,
|
|
220
|
+
`import CryptoKit
|
|
221
|
+
import Security
|
|
222
|
+
|
|
223
|
+
class SecureManager {
|
|
224
|
+
func hash(_ data: Data) -> SHA256Digest {
|
|
225
|
+
return SHA256.hash(data: data)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func encrypt(_ data: Data, using key: SymmetricKey) throws -> AES.GCM.SealedBox {
|
|
229
|
+
return try AES.GCM.seal(data, using: key)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func saveToKeychain(data: Data) {
|
|
233
|
+
let query: [String: Any] = [
|
|
234
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
235
|
+
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
|
236
|
+
kSecValueData as String: data,
|
|
237
|
+
]
|
|
238
|
+
SecItemAdd(query as CFDictionary, nil)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
`
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const result = await analyzer.scanPath(filePath);
|
|
245
|
+
expect(result.passed).toBe(true);
|
|
246
|
+
expect(result.issues).toHaveLength(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should skip commented-out code', async () => {
|
|
250
|
+
const filePath = path.join(tempDir, 'CommentedSecurity.swift');
|
|
251
|
+
await fs.writeFile(
|
|
252
|
+
filePath,
|
|
253
|
+
`import Foundation
|
|
254
|
+
|
|
255
|
+
// CC_MD5 is deprecated
|
|
256
|
+
// kCCAlgorithmDES should not be used
|
|
257
|
+
/* kSecAttrAccessibleAlways is insecure */
|
|
258
|
+
`
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const result = await analyzer.scanPath(filePath);
|
|
262
|
+
expect(result.issues).toHaveLength(0);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should scan directories', async () => {
|
|
266
|
+
const subDir = path.join(tempDir, 'SecurityScanDir');
|
|
267
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
268
|
+
|
|
269
|
+
await fs.writeFile(
|
|
270
|
+
path.join(subDir, 'Weak1.swift'),
|
|
271
|
+
`import CommonCrypto\nCC_MD5(data, len, digest)\n`
|
|
272
|
+
);
|
|
273
|
+
await fs.writeFile(
|
|
274
|
+
path.join(subDir, 'Weak2.swift'),
|
|
275
|
+
`let algorithm = kCCAlgorithmDES\n`
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const result = await analyzer.scanPath(subDir);
|
|
279
|
+
expect(result.issues.length).toBeGreaterThanOrEqual(2);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('analyze', () => {
|
|
284
|
+
it('should work with project interface', async () => {
|
|
285
|
+
const subDir = path.join(tempDir, 'AnalyzeSecDir');
|
|
286
|
+
await fs.mkdir(subDir, { recursive: true });
|
|
287
|
+
await fs.writeFile(
|
|
288
|
+
path.join(subDir, 'Clean.swift'),
|
|
289
|
+
`import UIKit\nclass VC: UIViewController {}\n`
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const result = await analyzer.analyze(mockProject, {
|
|
293
|
+
basePath: subDir,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(result.analyzer).toBe('Security Analyzer');
|
|
297
|
+
expect(result.passed).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|