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.
Files changed (205) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/actions/ios-review/action.yml +106 -0
  3. package/.github/workflows/ci.yml +103 -0
  4. package/.github/workflows/publish.yml +57 -0
  5. package/CHANGELOG.md +66 -0
  6. package/CONTRIBUTING.md +175 -0
  7. package/LICENSE +21 -0
  8. package/README.md +205 -0
  9. package/bitrise/step.sh +128 -0
  10. package/bitrise/step.yml +101 -0
  11. package/dist/analyzer.d.ts.map +1 -0
  12. package/dist/analyzers/asc-iap.d.ts.map +1 -0
  13. package/dist/analyzers/asc-metadata.d.ts.map +1 -0
  14. package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
  15. package/dist/analyzers/asc-version.d.ts.map +1 -0
  16. package/dist/analyzers/code-scanner.d.ts.map +1 -0
  17. package/dist/analyzers/deprecated-api.d.ts.map +1 -0
  18. package/dist/analyzers/entitlements.d.ts.map +1 -0
  19. package/dist/analyzers/index.d.ts.map +1 -0
  20. package/dist/analyzers/info-plist.d.ts.map +1 -0
  21. package/dist/analyzers/privacy.d.ts.map +1 -0
  22. package/dist/analyzers/private-api.d.ts.map +1 -0
  23. package/dist/analyzers/security.d.ts.map +1 -0
  24. package/dist/analyzers/ui-ux.d.ts.map +1 -0
  25. package/dist/asc/auth.d.ts.map +1 -0
  26. package/dist/asc/client.d.ts.map +1 -0
  27. package/dist/asc/endpoints/apps.d.ts.map +1 -0
  28. package/dist/asc/endpoints/iap.d.ts.map +1 -0
  29. package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
  30. package/dist/asc/endpoints/versions.d.ts.map +1 -0
  31. package/dist/asc/errors.d.ts.map +1 -0
  32. package/dist/asc/index.d.ts.map +1 -0
  33. package/dist/asc/types.d.ts.map +1 -0
  34. package/dist/badge/generator.d.ts.map +1 -0
  35. package/dist/badge/index.d.ts.map +1 -0
  36. package/dist/badge/types.d.ts.map +1 -0
  37. package/dist/cache/file-cache.d.ts.map +1 -0
  38. package/dist/cache/index.d.ts.map +1 -0
  39. package/dist/cache/types.d.ts.map +1 -0
  40. package/dist/cli/commands/help.d.ts.map +1 -0
  41. package/dist/cli/commands/scan.d.ts.map +1 -0
  42. package/dist/cli/commands/version.d.ts.map +1 -0
  43. package/dist/cli/index.d.ts.map +1 -0
  44. package/dist/cli/types.d.ts.map +1 -0
  45. package/dist/git/diff.d.ts.map +1 -0
  46. package/dist/git/index.d.ts.map +1 -0
  47. package/dist/git/types.d.ts.map +1 -0
  48. package/dist/guidelines/database.d.ts.map +1 -0
  49. package/dist/guidelines/index.d.ts.map +1 -0
  50. package/dist/guidelines/matcher.d.ts.map +1 -0
  51. package/dist/guidelines/types.d.ts.map +1 -0
  52. package/dist/history/comparator.d.ts.map +1 -0
  53. package/dist/history/index.d.ts.map +1 -0
  54. package/dist/history/store.d.ts.map +1 -0
  55. package/dist/history/types.d.ts.map +1 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +994 -0
  58. package/dist/parsers/index.d.ts.map +1 -0
  59. package/dist/parsers/plist.d.ts.map +1 -0
  60. package/dist/parsers/xcodeproj.d.ts.map +1 -0
  61. package/dist/progress/index.d.ts.map +1 -0
  62. package/dist/progress/reporter.d.ts.map +1 -0
  63. package/dist/progress/types.d.ts.map +1 -0
  64. package/dist/reports/html.d.ts.map +1 -0
  65. package/dist/reports/index.d.ts.map +1 -0
  66. package/dist/reports/json.d.ts.map +1 -0
  67. package/dist/reports/markdown.d.ts.map +1 -0
  68. package/dist/reports/types.d.ts.map +1 -0
  69. package/dist/rules/engine.d.ts.map +1 -0
  70. package/dist/rules/index.d.ts.map +1 -0
  71. package/dist/rules/loader.d.ts.map +1 -0
  72. package/dist/rules/types.d.ts.map +1 -0
  73. package/dist/types/index.d.ts.map +1 -0
  74. package/docs/ANALYZERS.md +237 -0
  75. package/docs/API.md +308 -0
  76. package/docs/BADGES.md +130 -0
  77. package/docs/CI_CD.md +283 -0
  78. package/docs/CLI.md +140 -0
  79. package/docs/REPORTS.md +212 -0
  80. package/docs/ROADMAP.md +267 -0
  81. package/docs/RULES.md +182 -0
  82. package/docs/SECURITY.md +89 -0
  83. package/docs/TROUBLESHOOTING.md +227 -0
  84. package/docs/tutorials/ASC_SETUP.md +188 -0
  85. package/docs/tutorials/CI_INTEGRATION.md +292 -0
  86. package/docs/tutorials/CUSTOM_RULES.md +291 -0
  87. package/docs/tutorials/GETTING_STARTED.md +226 -0
  88. package/docs/video-scripts/01-introduction.md +106 -0
  89. package/docs/video-scripts/02-cli-usage.md +120 -0
  90. package/docs/video-scripts/03-ci-integration.md +198 -0
  91. package/eslint.config.js +33 -0
  92. package/examples/.ios-review-rules.json +82 -0
  93. package/examples/bitrise-workflow.yml +129 -0
  94. package/examples/fastlane-lane.rb +71 -0
  95. package/examples/github-action.yml +147 -0
  96. package/fastlane/Fastfile.example +114 -0
  97. package/fastlane/README.md +99 -0
  98. package/jest.config.js +36 -0
  99. package/package.json +65 -0
  100. package/scripts/benchmark.ts +112 -0
  101. package/scripts/debug-parser.ts +37 -0
  102. package/scripts/debug-pbxproj.ts +36 -0
  103. package/scripts/debug-specific.ts +47 -0
  104. package/scripts/test-analyze.ts +67 -0
  105. package/scripts/xcode-cloud-review.sh +167 -0
  106. package/src/analyzer.ts +227 -0
  107. package/src/analyzers/asc-iap.ts +300 -0
  108. package/src/analyzers/asc-metadata.ts +326 -0
  109. package/src/analyzers/asc-screenshots.ts +310 -0
  110. package/src/analyzers/asc-version.ts +368 -0
  111. package/src/analyzers/code-scanner.ts +408 -0
  112. package/src/analyzers/deprecated-api.ts +390 -0
  113. package/src/analyzers/entitlements.ts +345 -0
  114. package/src/analyzers/index.ts +12 -0
  115. package/src/analyzers/info-plist.ts +409 -0
  116. package/src/analyzers/privacy.ts +376 -0
  117. package/src/analyzers/private-api.ts +377 -0
  118. package/src/analyzers/security.ts +327 -0
  119. package/src/analyzers/ui-ux.ts +509 -0
  120. package/src/asc/auth.ts +204 -0
  121. package/src/asc/client.ts +258 -0
  122. package/src/asc/endpoints/apps.ts +115 -0
  123. package/src/asc/endpoints/iap.ts +171 -0
  124. package/src/asc/endpoints/screenshots.ts +164 -0
  125. package/src/asc/endpoints/versions.ts +174 -0
  126. package/src/asc/errors.ts +109 -0
  127. package/src/asc/index.ts +108 -0
  128. package/src/asc/types.ts +369 -0
  129. package/src/badge/generator.ts +48 -0
  130. package/src/badge/index.ts +2 -0
  131. package/src/badge/types.ts +5 -0
  132. package/src/cache/file-cache.ts +75 -0
  133. package/src/cache/index.ts +2 -0
  134. package/src/cache/types.ts +10 -0
  135. package/src/cli/commands/help.ts +41 -0
  136. package/src/cli/commands/scan.ts +44 -0
  137. package/src/cli/commands/version.ts +12 -0
  138. package/src/cli/index.ts +92 -0
  139. package/src/cli/types.ts +17 -0
  140. package/src/git/diff.ts +21 -0
  141. package/src/git/index.ts +2 -0
  142. package/src/git/types.ts +5 -0
  143. package/src/guidelines/database.ts +344 -0
  144. package/src/guidelines/index.ts +4 -0
  145. package/src/guidelines/matcher.ts +84 -0
  146. package/src/guidelines/types.ts +28 -0
  147. package/src/history/comparator.ts +114 -0
  148. package/src/history/index.ts +3 -0
  149. package/src/history/store.ts +135 -0
  150. package/src/history/types.ts +40 -0
  151. package/src/index.ts +1113 -0
  152. package/src/parsers/index.ts +3 -0
  153. package/src/parsers/plist.ts +253 -0
  154. package/src/parsers/xcodeproj.ts +265 -0
  155. package/src/progress/index.ts +2 -0
  156. package/src/progress/reporter.ts +65 -0
  157. package/src/progress/types.ts +9 -0
  158. package/src/reports/html.ts +322 -0
  159. package/src/reports/index.ts +20 -0
  160. package/src/reports/json.ts +92 -0
  161. package/src/reports/markdown.ts +187 -0
  162. package/src/reports/types.ts +26 -0
  163. package/src/rules/engine.ts +121 -0
  164. package/src/rules/index.ts +3 -0
  165. package/src/rules/loader.ts +83 -0
  166. package/src/rules/types.ts +25 -0
  167. package/src/types/index.ts +247 -0
  168. package/tests/analyzer.test.ts +142 -0
  169. package/tests/analyzers/asc-iap.test.ts +228 -0
  170. package/tests/analyzers/asc-metadata.test.ts +210 -0
  171. package/tests/analyzers/asc-screenshots.test.ts +135 -0
  172. package/tests/analyzers/asc-version.test.ts +259 -0
  173. package/tests/analyzers/code-scanner.test.ts +745 -0
  174. package/tests/analyzers/deprecated-api.test.ts +286 -0
  175. package/tests/analyzers/entitlements.test.ts +411 -0
  176. package/tests/analyzers/info-plist.test.ts +148 -0
  177. package/tests/analyzers/privacy.test.ts +623 -0
  178. package/tests/analyzers/private-api.test.ts +255 -0
  179. package/tests/analyzers/security.test.ts +300 -0
  180. package/tests/analyzers/ui-ux.test.ts +357 -0
  181. package/tests/asc/auth.test.ts +189 -0
  182. package/tests/asc/client.test.ts +207 -0
  183. package/tests/asc/endpoints.test.ts +1359 -0
  184. package/tests/badge/generator.test.ts +73 -0
  185. package/tests/cache/file-cache.test.ts +124 -0
  186. package/tests/cli/cli-index.test.ts +510 -0
  187. package/tests/cli/commands.test.ts +67 -0
  188. package/tests/cli/scan.test.ts +152 -0
  189. package/tests/git/diff.test.ts +69 -0
  190. package/tests/guidelines/matcher.test.ts +209 -0
  191. package/tests/history/comparator.test.ts +272 -0
  192. package/tests/history/store.test.ts +200 -0
  193. package/tests/integration/cli.test.ts +95 -0
  194. package/tests/integration/e2e.test.ts +130 -0
  195. package/tests/parsers/plist.test.ts +240 -0
  196. package/tests/parsers/xcodeproj.test.ts +289 -0
  197. package/tests/progress/reporter.test.ts +117 -0
  198. package/tests/reports/html.test.ts +176 -0
  199. package/tests/reports/json.test.ts +235 -0
  200. package/tests/reports/markdown.test.ts +196 -0
  201. package/tests/rules/engine.test.ts +229 -0
  202. package/tests/rules/loader.test.ts +187 -0
  203. package/tests/setup.ts +15 -0
  204. package/tsconfig.json +27 -0
  205. 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
+ });