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,745 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { CodeScanner } from '../../src/analyzers/code-scanner.js';
5
+ import type { XcodeProject, AnalyzerOptions } from '../../src/types/index.js';
6
+
7
+ describe('CodeScanner', () => {
8
+ let scanner: CodeScanner;
9
+ let tempDir: string;
10
+
11
+ beforeAll(async () => {
12
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'code-scanner-test-'));
13
+ });
14
+
15
+ afterAll(async () => {
16
+ await fs.rm(tempDir, { recursive: true, force: true });
17
+ });
18
+
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ scanner = new CodeScanner();
22
+ });
23
+
24
+ function makeMockProject(overrides?: {
25
+ sourceFiles?: string[];
26
+ targets?: Array<{
27
+ name: string;
28
+ type: 'application' | 'framework';
29
+ sourceFiles: string[];
30
+ }>;
31
+ }): XcodeProject {
32
+ const targets = overrides?.targets ?? [
33
+ {
34
+ name: 'TestApp',
35
+ type: 'application' as const,
36
+ bundleIdentifier: 'com.test.app',
37
+ deploymentTarget: '16.0',
38
+ sourceFiles: overrides?.sourceFiles ?? [],
39
+ },
40
+ ];
41
+ return {
42
+ path: '/test/TestApp.xcodeproj',
43
+ name: 'TestApp',
44
+ targets: targets.map((t) => ({
45
+ bundleIdentifier: 'com.test.app',
46
+ deploymentTarget: '16.0',
47
+ ...t,
48
+ })),
49
+ configurations: ['Debug', 'Release'],
50
+ };
51
+ }
52
+
53
+ function makeOptions(overrides?: Partial<AnalyzerOptions>): AnalyzerOptions {
54
+ return {
55
+ basePath: tempDir,
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ describe('analyze - pattern detection', () => {
61
+ let sourceDir: string;
62
+ let swiftFile: string;
63
+
64
+ beforeAll(async () => {
65
+ sourceDir = path.join(tempDir, 'patterns');
66
+ await fs.mkdir(sourceDir, { recursive: true });
67
+
68
+ swiftFile = path.join(sourceDir, 'ViewController.swift');
69
+ await fs.writeFile(
70
+ swiftFile,
71
+ `import UIKit
72
+ import WebKit
73
+
74
+ class ViewController: UIViewController {
75
+ let apiKey = "sk-test1234567890abcdef"
76
+ let awsKey = "AKIAIOSFODNN7EXAMPLE"
77
+ let url = "http://api.example.com/data"
78
+ let password = "mysecretpassword"
79
+
80
+ override func viewDidLoad() {
81
+ super.viewDidLoad()
82
+ print("Debug: loaded")
83
+ NSLog("Debug log")
84
+ debugPrint("verbose")
85
+
86
+ let value = someOptional!.property
87
+ let result = try! someThrowingFunc()
88
+
89
+ // TODO: fix this before release
90
+ // FIXME: hack that needs cleanup
91
+
92
+ let webView = UIWebView()
93
+ let ipsum = "lorem ipsum dolor sit amet"
94
+ }
95
+ }
96
+ `,
97
+ 'utf-8'
98
+ );
99
+ });
100
+
101
+ it('should detect hardcoded AWS keys', async () => {
102
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
103
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
104
+
105
+ const awsIssue = result.issues.find((i) => i.id === 'aws-key');
106
+ expect(awsIssue).toBeDefined();
107
+ expect(awsIssue?.severity).toBe('error');
108
+ expect(awsIssue?.filePath).toBe(swiftFile);
109
+ });
110
+
111
+ it('should detect print/NSLog/debugPrint statements', async () => {
112
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
113
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
114
+
115
+ const printIssues = result.issues.filter((i) => i.id === 'print-statement');
116
+ expect(printIssues.length).toBeGreaterThanOrEqual(1);
117
+ expect(printIssues[0]?.severity).toBe('info');
118
+ });
119
+
120
+ it('should detect force unwrap usage', async () => {
121
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
122
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
123
+
124
+ const forceUnwrap = result.issues.find((i) => i.id === 'force-unwrap');
125
+ expect(forceUnwrap).toBeDefined();
126
+ expect(forceUnwrap?.severity).toBe('info');
127
+ });
128
+
129
+ it('should detect deprecated UIWebView usage', async () => {
130
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
131
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
132
+
133
+ const webViewIssue = result.issues.find((i) => i.id === 'deprecated-uiwebview');
134
+ expect(webViewIssue).toBeDefined();
135
+ expect(webViewIssue?.severity).toBe('error');
136
+ expect(webViewIssue?.guideline).toBe('ITMS-90809');
137
+ });
138
+
139
+ it('should detect insecure HTTP URLs', async () => {
140
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
141
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
142
+
143
+ const httpIssue = result.issues.find((i) => i.id === 'insecure-http');
144
+ expect(httpIssue).toBeDefined();
145
+ expect(httpIssue?.severity).toBe('warning');
146
+ });
147
+
148
+ it('should detect TODO/FIXME comments', async () => {
149
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
150
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
151
+
152
+ const todoIssues = result.issues.filter((i) => i.id === 'todo-comment');
153
+ expect(todoIssues.length).toBeGreaterThanOrEqual(2);
154
+ });
155
+
156
+ it('should detect placeholder text', async () => {
157
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
158
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
159
+
160
+ const placeholderIssue = result.issues.find((i) => i.id === 'placeholder-text');
161
+ expect(placeholderIssue).toBeDefined();
162
+ expect(placeholderIssue?.severity).toBe('warning');
163
+ });
164
+
165
+ it('should detect hardcoded passwords', async () => {
166
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
167
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
168
+
169
+ const pwIssue = result.issues.find((i) => i.id === 'hardcoded-password');
170
+ expect(pwIssue).toBeDefined();
171
+ expect(pwIssue?.severity).toBe('error');
172
+ });
173
+
174
+ it('should mark result as not passed when errors exist', async () => {
175
+ const project = makeMockProject({ sourceFiles: [swiftFile] });
176
+ const result = await scanner.analyze(project, makeOptions({ basePath: sourceDir }));
177
+
178
+ expect(result.passed).toBe(false);
179
+ expect(result.issues.some((i) => i.severity === 'error')).toBe(true);
180
+ });
181
+ });
182
+
183
+ describe('analyze - ObjC patterns', () => {
184
+ let objcDir: string;
185
+ let objcFile: string;
186
+
187
+ beforeAll(async () => {
188
+ objcDir = path.join(tempDir, 'objc-patterns');
189
+ await fs.mkdir(objcDir, { recursive: true });
190
+
191
+ objcFile = path.join(objcDir, 'LegacyController.m');
192
+ await fs.writeFile(
193
+ objcFile,
194
+ `#import <UIKit/UIKit.h>
195
+ #import <AddressBook/AddressBook.h>
196
+
197
+ @implementation LegacyController
198
+
199
+ - (void)viewDidLoad {
200
+ [super viewDidLoad];
201
+ NSLog(@"View loaded");
202
+ UIWebView *webView = [[UIWebView alloc] init];
203
+ ABAddressBookRef addressBook = ABAddressBookCreate();
204
+ }
205
+
206
+ @end
207
+ `,
208
+ 'utf-8'
209
+ );
210
+ });
211
+
212
+ it('should detect UIWebView in ObjC files', async () => {
213
+ const project = makeMockProject({ sourceFiles: [objcFile] });
214
+ const result = await scanner.analyze(project, makeOptions({ basePath: objcDir }));
215
+
216
+ const webViewIssue = result.issues.find((i) => i.id === 'deprecated-uiwebview');
217
+ expect(webViewIssue).toBeDefined();
218
+ });
219
+
220
+ it('should detect deprecated AddressBook framework', async () => {
221
+ const project = makeMockProject({ sourceFiles: [objcFile] });
222
+ const result = await scanner.analyze(project, makeOptions({ basePath: objcDir }));
223
+
224
+ const abIssue = result.issues.find((i) => i.id === 'deprecated-addressbook');
225
+ expect(abIssue).toBeDefined();
226
+ expect(abIssue?.severity).toBe('warning');
227
+ });
228
+ });
229
+
230
+ describe('analyze - hardcoded IPv4 and test server URLs', () => {
231
+ let ipDir: string;
232
+ let ipFile: string;
233
+
234
+ beforeAll(async () => {
235
+ ipDir = path.join(tempDir, 'ip-patterns');
236
+ await fs.mkdir(ipDir, { recursive: true });
237
+
238
+ ipFile = path.join(ipDir, 'Network.swift');
239
+ await fs.writeFile(
240
+ ipFile,
241
+ `import Foundation
242
+
243
+ let serverIP = "192.168.1.100"
244
+ let testURL = "http://staging.example.com/api"
245
+ let debugURL = "http://localhost:8080/test"
246
+ let devServer = "http://dev.myapp.com/endpoint"
247
+ `,
248
+ 'utf-8'
249
+ );
250
+ });
251
+
252
+ it('should detect hardcoded IPv4 addresses', async () => {
253
+ const project = makeMockProject({ sourceFiles: [ipFile] });
254
+ const result = await scanner.analyze(project, makeOptions({ basePath: ipDir }));
255
+
256
+ const ipIssue = result.issues.find((i) => i.id === 'hardcoded-ipv4');
257
+ expect(ipIssue).toBeDefined();
258
+ expect(ipIssue?.severity).toBe('warning');
259
+ });
260
+
261
+ it('should detect test/staging server URLs', async () => {
262
+ const project = makeMockProject({ sourceFiles: [ipFile] });
263
+ const result = await scanner.analyze(project, makeOptions({ basePath: ipDir }));
264
+
265
+ const testUrlIssues = result.issues.filter((i) => i.id === 'test-server-url');
266
+ expect(testUrlIssues.length).toBeGreaterThanOrEqual(1);
267
+ });
268
+ });
269
+
270
+ describe('analyze - debug ifdef', () => {
271
+ it('should detect #if DEBUG blocks in Swift files', async () => {
272
+ const subDir = path.join(tempDir, 'debug-ifdef');
273
+ await fs.mkdir(subDir, { recursive: true });
274
+
275
+ const filePath = path.join(subDir, 'Config.swift');
276
+ await fs.writeFile(
277
+ filePath,
278
+ `import Foundation
279
+
280
+ #if DEBUG
281
+ let baseURL = "http://localhost:3000"
282
+ #else
283
+ let baseURL = "https://api.production.com"
284
+ #endif
285
+ `,
286
+ 'utf-8'
287
+ );
288
+
289
+ const project = makeMockProject({ sourceFiles: [filePath] });
290
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
291
+
292
+ const debugIssue = result.issues.find((i) => i.id === 'debug-ifdef');
293
+ expect(debugIssue).toBeDefined();
294
+ expect(debugIssue?.severity).toBe('info');
295
+ });
296
+ });
297
+
298
+ describe('analyze - hardcoded API key pattern', () => {
299
+ it('should detect api_key = "..." pattern', async () => {
300
+ const subDir = path.join(tempDir, 'apikey-pattern');
301
+ await fs.mkdir(subDir, { recursive: true });
302
+
303
+ const filePath = path.join(subDir, 'Keys.swift');
304
+ await fs.writeFile(
305
+ filePath,
306
+ `import Foundation
307
+
308
+ let api_key = "abcdef1234567890abcdef"
309
+ let secret_key = "secretvalue12345678901234"
310
+ `,
311
+ 'utf-8'
312
+ );
313
+
314
+ const project = makeMockProject({ sourceFiles: [filePath] });
315
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
316
+
317
+ const apiKeyIssue = result.issues.find((i) => i.id === 'hardcoded-api-key');
318
+ expect(apiKeyIssue).toBeDefined();
319
+ expect(apiKeyIssue?.severity).toBe('error');
320
+ });
321
+ });
322
+
323
+ describe('analyze - false positive handling', () => {
324
+ it('should skip patterns in commented-out lines', async () => {
325
+ const subDir = path.join(tempDir, 'false-positive-comments');
326
+ await fs.mkdir(subDir, { recursive: true });
327
+
328
+ const filePath = path.join(subDir, 'Commented.swift');
329
+ await fs.writeFile(
330
+ filePath,
331
+ `import Foundation
332
+
333
+ // let awsKey = "AKIAIOSFODNN7EXAMPLE"
334
+ /* password = "mysecret1234" */
335
+ * let webView = UIWebView()
336
+ `,
337
+ 'utf-8'
338
+ );
339
+
340
+ const project = makeMockProject({ sourceFiles: [filePath] });
341
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
342
+
343
+ // Commented-out patterns should be filtered as false positives
344
+ // (except TODO/FIXME which are expected in comments)
345
+ const awsIssue = result.issues.find((i) => i.id === 'aws-key');
346
+ expect(awsIssue).toBeUndefined();
347
+ });
348
+
349
+ it('should not flag @IBOutlet force unwraps as issues', async () => {
350
+ const subDir = path.join(tempDir, 'false-positive-iboutlet');
351
+ await fs.mkdir(subDir, { recursive: true });
352
+
353
+ const filePath = path.join(subDir, 'Outlet.swift');
354
+ await fs.writeFile(
355
+ filePath,
356
+ `import UIKit
357
+
358
+ class MyVC: UIViewController {
359
+ @IBOutlet weak var label: UILabel!
360
+ @IBAction func tapped(_ sender: UIButton!) {
361
+ }
362
+ }
363
+ `,
364
+ 'utf-8'
365
+ );
366
+
367
+ const project = makeMockProject({ sourceFiles: [filePath] });
368
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
369
+
370
+ // IBOutlet/IBAction force unwraps should be treated as false positives
371
+ const forceUnwrapIssues = result.issues.filter(
372
+ (i) => i.id === 'force-unwrap' && i.filePath === filePath
373
+ );
374
+ expect(forceUnwrapIssues).toHaveLength(0);
375
+ });
376
+
377
+ it('should skip lines containing XCTest as false positives', async () => {
378
+ const subDir = path.join(tempDir, 'false-positive-xctest');
379
+ await fs.mkdir(subDir, { recursive: true });
380
+
381
+ const filePath = path.join(subDir, 'HelperForTest.swift');
382
+ await fs.writeFile(
383
+ filePath,
384
+ `import Foundation
385
+ import XCTest let password = "testpassword1234"
386
+ `,
387
+ 'utf-8'
388
+ );
389
+
390
+ const project = makeMockProject({ sourceFiles: [filePath] });
391
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
392
+
393
+ // The XCTest line should be filtered out
394
+ const pwIssue = result.issues.find(
395
+ (i) => i.id === 'hardcoded-password' && i.filePath === filePath
396
+ );
397
+ expect(pwIssue).toBeUndefined();
398
+ });
399
+
400
+ it('should not flag insecure HTTP URLs next to NSExceptionDomains', async () => {
401
+ const subDir = path.join(tempDir, 'false-positive-ats');
402
+ await fs.mkdir(subDir, { recursive: true });
403
+
404
+ const filePath = path.join(subDir, 'ATS.swift');
405
+ await fs.writeFile(
406
+ filePath,
407
+ `import Foundation
408
+ let domain = "http://example.com" // NSExceptionDomains
409
+ `,
410
+ 'utf-8'
411
+ );
412
+
413
+ const project = makeMockProject({ sourceFiles: [filePath] });
414
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
415
+
416
+ const httpIssue = result.issues.find(
417
+ (i) => i.id === 'insecure-http' && i.filePath === filePath
418
+ );
419
+ expect(httpIssue).toBeUndefined();
420
+ });
421
+ });
422
+
423
+ describe('analyze - file type filtering', () => {
424
+ it('should not flag print statements in .h header files', async () => {
425
+ const subDir = path.join(tempDir, 'filetype-filter');
426
+ await fs.mkdir(subDir, { recursive: true });
427
+
428
+ const headerFile = path.join(subDir, 'Header.h');
429
+ await fs.writeFile(
430
+ headerFile,
431
+ `// Header file
432
+ void print(const char *msg);
433
+ `,
434
+ 'utf-8'
435
+ );
436
+
437
+ const project = makeMockProject({ sourceFiles: [headerFile] });
438
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
439
+
440
+ // print-statement pattern has fileTypes: ['.swift', '.m', '.mm']
441
+ // .h files should not match
442
+ const printIssues = result.issues.filter(
443
+ (i) => i.id === 'print-statement' && i.filePath === headerFile
444
+ );
445
+ expect(printIssues).toHaveLength(0);
446
+ });
447
+ });
448
+
449
+ describe('analyze - glob fallback and target filtering', () => {
450
+ it('should find source files via glob when no source files in target', async () => {
451
+ const subDir = path.join(tempDir, 'glob-fallback');
452
+ await fs.mkdir(subDir, { recursive: true });
453
+
454
+ await fs.writeFile(
455
+ path.join(subDir, 'Found.swift'),
456
+ `import UIKit
457
+ let webView = UIWebView()
458
+ `,
459
+ 'utf-8'
460
+ );
461
+
462
+ const project = makeMockProject({ sourceFiles: [] });
463
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
464
+
465
+ const webViewIssue = result.issues.find((i) => i.id === 'deprecated-uiwebview');
466
+ expect(webViewIssue).toBeDefined();
467
+ });
468
+
469
+ it('should filter targets by options.targetName', async () => {
470
+ const subDir = path.join(tempDir, 'target-name-filter');
471
+ await fs.mkdir(subDir, { recursive: true });
472
+
473
+ const mainFile = path.join(subDir, 'Main.swift');
474
+ await fs.writeFile(mainFile, 'let webView = UIWebView()\n', 'utf-8');
475
+
476
+ const extensionFile = path.join(subDir, 'Extension.swift');
477
+ await fs.writeFile(extensionFile, 'import Foundation\n', 'utf-8');
478
+
479
+ const project: XcodeProject = {
480
+ path: '/test/TestApp.xcodeproj',
481
+ name: 'TestApp',
482
+ targets: [
483
+ {
484
+ name: 'MainApp',
485
+ type: 'application',
486
+ bundleIdentifier: 'com.test.main',
487
+ deploymentTarget: '16.0',
488
+ sourceFiles: [mainFile],
489
+ },
490
+ {
491
+ name: 'CleanExtension',
492
+ type: 'appExtension',
493
+ bundleIdentifier: 'com.test.ext',
494
+ deploymentTarget: '16.0',
495
+ sourceFiles: [extensionFile],
496
+ },
497
+ ],
498
+ configurations: ['Debug', 'Release'],
499
+ };
500
+
501
+ // Only scan CleanExtension
502
+ const result = await scanner.analyze(project, makeOptions({
503
+ basePath: subDir,
504
+ targetName: 'CleanExtension',
505
+ }));
506
+
507
+ // CleanExtension has a clean file, MainApp's UIWebView should not appear
508
+ const webViewIssue = result.issues.find((i) => i.id === 'deprecated-uiwebview');
509
+ expect(webViewIssue).toBeUndefined();
510
+ expect(result.passed).toBe(true);
511
+ });
512
+
513
+ it('should process multiple targets when no targetName specified', async () => {
514
+ const subDir = path.join(tempDir, 'multi-target');
515
+ await fs.mkdir(subDir, { recursive: true });
516
+
517
+ const file1 = path.join(subDir, 'App1.swift');
518
+ await fs.writeFile(file1, 'let webView = UIWebView()\n', 'utf-8');
519
+
520
+ const file2 = path.join(subDir, 'App2.swift');
521
+ await fs.writeFile(file2, 'let key = "AKIAIOSFODNN7EXAMPLE"\n', 'utf-8');
522
+
523
+ const project: XcodeProject = {
524
+ path: '/test/TestApp.xcodeproj',
525
+ name: 'TestApp',
526
+ targets: [
527
+ {
528
+ name: 'App1',
529
+ type: 'application',
530
+ bundleIdentifier: 'com.test.app1',
531
+ deploymentTarget: '16.0',
532
+ sourceFiles: [file1],
533
+ },
534
+ {
535
+ name: 'App2',
536
+ type: 'application',
537
+ bundleIdentifier: 'com.test.app2',
538
+ deploymentTarget: '16.0',
539
+ sourceFiles: [file2],
540
+ },
541
+ ],
542
+ configurations: ['Debug', 'Release'],
543
+ };
544
+
545
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
546
+
547
+ // Both targets' files should be scanned
548
+ const webViewIssue = result.issues.find((i) => i.id === 'deprecated-uiwebview');
549
+ const awsIssue = result.issues.find((i) => i.id === 'aws-key');
550
+ expect(webViewIssue).toBeDefined();
551
+ expect(awsIssue).toBeDefined();
552
+ });
553
+ });
554
+
555
+ describe('analyze - changedFiles filtering', () => {
556
+ it('should only scan files that are in the changedFiles set', async () => {
557
+ const subDir = path.join(tempDir, 'changed-files');
558
+ await fs.mkdir(subDir, { recursive: true });
559
+
560
+ const changedFile = path.join(subDir, 'Changed.swift');
561
+ await fs.writeFile(changedFile, 'let webView = UIWebView()\n', 'utf-8');
562
+
563
+ const unchangedFile = path.join(subDir, 'Unchanged.swift');
564
+ await fs.writeFile(unchangedFile, 'let key = "AKIAIOSFODNN7EXAMPLE"\n', 'utf-8');
565
+
566
+ const project = makeMockProject({
567
+ sourceFiles: [changedFile, unchangedFile],
568
+ });
569
+
570
+ const result = await scanner.analyze(project, makeOptions({
571
+ basePath: subDir,
572
+ changedFiles: [changedFile],
573
+ }));
574
+
575
+ // Only changedFile should be scanned
576
+ const webViewIssue = result.issues.find((i) => i.id === 'deprecated-uiwebview');
577
+ expect(webViewIssue).toBeDefined();
578
+
579
+ // unchangedFile has AWS key but should not be scanned
580
+ const awsIssue = result.issues.find((i) => i.id === 'aws-key');
581
+ expect(awsIssue).toBeUndefined();
582
+ });
583
+
584
+ it('should return no issues when changedFiles is empty', async () => {
585
+ const subDir = path.join(tempDir, 'changed-empty');
586
+ await fs.mkdir(subDir, { recursive: true });
587
+
588
+ const file = path.join(subDir, 'HasIssues.swift');
589
+ await fs.writeFile(file, 'let webView = UIWebView()\n', 'utf-8');
590
+
591
+ const project = makeMockProject({ sourceFiles: [file] });
592
+
593
+ const result = await scanner.analyze(project, makeOptions({
594
+ basePath: subDir,
595
+ changedFiles: [],
596
+ }));
597
+
598
+ expect(result.issues).toHaveLength(0);
599
+ expect(result.passed).toBe(true);
600
+ });
601
+ });
602
+
603
+ describe('scanPath', () => {
604
+ it('should scan a single file directly', async () => {
605
+ const subDir = path.join(tempDir, 'scanpath-file');
606
+ await fs.mkdir(subDir, { recursive: true });
607
+
608
+ const filePath = path.join(subDir, 'Single.swift');
609
+ await fs.writeFile(
610
+ filePath,
611
+ `import UIKit
612
+ let webView = UIWebView()
613
+ let key = "AKIAIOSFODNN7EXAMPLE"
614
+ print("debug info")
615
+ `,
616
+ 'utf-8'
617
+ );
618
+
619
+ const result = await scanner.scanPath(filePath);
620
+
621
+ expect(result.analyzer).toBe('Code Scanner');
622
+ expect(result.passed).toBe(false);
623
+ expect(result.issues.some((i) => i.id === 'deprecated-uiwebview')).toBe(true);
624
+ expect(result.issues.some((i) => i.id === 'aws-key')).toBe(true);
625
+ expect(result.duration).toBeGreaterThanOrEqual(0);
626
+ });
627
+
628
+ it('should scan a directory for all source files', async () => {
629
+ const subDir = path.join(tempDir, 'scanpath-dir');
630
+ await fs.mkdir(subDir, { recursive: true });
631
+
632
+ await fs.writeFile(
633
+ path.join(subDir, 'FileA.swift'),
634
+ 'let webView = UIWebView()\n',
635
+ 'utf-8'
636
+ );
637
+ await fs.writeFile(
638
+ path.join(subDir, 'FileB.swift'),
639
+ 'let key = "AKIAIOSFODNN7EXAMPLE"\n',
640
+ 'utf-8'
641
+ );
642
+
643
+ const result = await scanner.scanPath(subDir);
644
+
645
+ expect(result.issues.some((i) => i.id === 'deprecated-uiwebview')).toBe(true);
646
+ expect(result.issues.some((i) => i.id === 'aws-key')).toBe(true);
647
+ });
648
+
649
+ it('should filter patterns when pattern IDs are provided', async () => {
650
+ const subDir = path.join(tempDir, 'scanpath-patterns');
651
+ await fs.mkdir(subDir, { recursive: true });
652
+
653
+ const filePath = path.join(subDir, 'Mixed.swift');
654
+ await fs.writeFile(
655
+ filePath,
656
+ `import UIKit
657
+ let webView = UIWebView()
658
+ let key = "AKIAIOSFODNN7EXAMPLE"
659
+ print("debug info")
660
+ // TODO: fix this
661
+ `,
662
+ 'utf-8'
663
+ );
664
+
665
+ // Only scan for deprecated-uiwebview
666
+ const result = await scanner.scanPath(filePath, ['deprecated-uiwebview']);
667
+
668
+ const webViewIssues = result.issues.filter((i) => i.id === 'deprecated-uiwebview');
669
+ expect(webViewIssues.length).toBeGreaterThanOrEqual(1);
670
+
671
+ // Other patterns should NOT be detected
672
+ const awsIssue = result.issues.find((i) => i.id === 'aws-key');
673
+ expect(awsIssue).toBeUndefined();
674
+
675
+ const printIssue = result.issues.find((i) => i.id === 'print-statement');
676
+ expect(printIssue).toBeUndefined();
677
+ });
678
+ });
679
+
680
+ describe('result metadata', () => {
681
+ it('should include analyzer name and duration', async () => {
682
+ const subDir = path.join(tempDir, 'metadata');
683
+ await fs.mkdir(subDir, { recursive: true });
684
+
685
+ const filePath = path.join(subDir, 'Clean.swift');
686
+ await fs.writeFile(filePath, 'import Foundation\n', 'utf-8');
687
+
688
+ const project = makeMockProject({ sourceFiles: [filePath] });
689
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
690
+
691
+ expect(result.analyzer).toBe('Code Scanner');
692
+ expect(result.duration).toBeGreaterThanOrEqual(0);
693
+ expect(result.passed).toBe(true);
694
+ expect(result.issues).toHaveLength(0);
695
+ });
696
+
697
+ it('should truncate long match strings in issue descriptions', async () => {
698
+ const subDir = path.join(tempDir, 'truncate');
699
+ await fs.mkdir(subDir, { recursive: true });
700
+
701
+ const filePath = path.join(subDir, 'LongMatch.swift');
702
+ // Create a very long insecure URL that will exceed truncation limit
703
+ const longUrl = `"http://very-long-domain-name-that-goes-on-and-on-and-on-for-testing-purposes.example.com/some/really/long/path/that/keeps/going"`;
704
+ await fs.writeFile(
705
+ filePath,
706
+ `import Foundation
707
+ let url = ${longUrl}
708
+ `,
709
+ 'utf-8'
710
+ );
711
+
712
+ const project = makeMockProject({ sourceFiles: [filePath] });
713
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
714
+
715
+ const httpIssue = result.issues.find((i) => i.id === 'insecure-http');
716
+ expect(httpIssue).toBeDefined();
717
+ // Long matches get truncated with "..."
718
+ if (httpIssue && httpIssue.description.includes('...')) {
719
+ expect(httpIssue.description).toContain('...');
720
+ }
721
+ });
722
+ });
723
+
724
+ describe('issue limit per pattern per file', () => {
725
+ it('should limit issues per pattern to 5 per file', async () => {
726
+ const subDir = path.join(tempDir, 'issue-limit');
727
+ await fs.mkdir(subDir, { recursive: true });
728
+
729
+ const filePath = path.join(subDir, 'ManyTodos.swift');
730
+ const lines = ['import Foundation'];
731
+ for (let i = 0; i < 10; i++) {
732
+ lines.push(`// TODO: task ${i}`);
733
+ }
734
+ await fs.writeFile(filePath, lines.join('\n') + '\n', 'utf-8');
735
+
736
+ const project = makeMockProject({ sourceFiles: [filePath] });
737
+ const result = await scanner.analyze(project, makeOptions({ basePath: subDir }));
738
+
739
+ const todoIssues = result.issues.filter(
740
+ (i) => i.id === 'todo-comment' && i.filePath === filePath
741
+ );
742
+ expect(todoIssues.length).toBeLessThanOrEqual(5);
743
+ });
744
+ });
745
+ });