@capgo/capgo-sec 1.0.6 → 1.1.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/dist/cli/index.js +14987 -7
- package/dist/index.js +14985 -5
- package/package.json +1 -1
- package/src/rules/network.ts +4 -2
- package/src/scanners/engine.ts +241 -5
- package/src/types.ts +13 -0
- package/src/utils/reporter.ts +28 -0
- package/test/rules.test.ts +15 -0
- package/test/scanner.test.ts +71 -1
package/package.json
CHANGED
package/src/rules/network.ts
CHANGED
|
@@ -90,13 +90,15 @@ export const networkRules: Rule[] = [
|
|
|
90
90
|
description: 'Detects cleartext traffic enabled in Capacitor configuration',
|
|
91
91
|
severity: 'critical',
|
|
92
92
|
category: 'network',
|
|
93
|
-
|
|
93
|
+
// Support any capacitor config extension (ts/js/json/mjs/cjs, etc).
|
|
94
|
+
filePatterns: ['**/capacitor.config.*'],
|
|
94
95
|
check: (content: string, filePath: string): Finding[] => {
|
|
95
96
|
const findings: Finding[] = [];
|
|
96
97
|
const lines = content.split('\n');
|
|
97
98
|
|
|
98
99
|
// Check for cleartext: true in server config
|
|
99
|
-
|
|
100
|
+
// Works for TS/JS (`cleartext: true`) and JSON (`"cleartext": true`)
|
|
101
|
+
const cleartextPattern = /\bcleartext\b["']?\s*:\s*true/i;
|
|
100
102
|
|
|
101
103
|
if (cleartextPattern.test(content)) {
|
|
102
104
|
const match = content.match(cleartextPattern);
|
package/src/scanners/engine.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fg from 'fast-glob';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from '@babel/parser';
|
|
2
4
|
import { allRules, ruleCount } from '../rules/index.js';
|
|
3
|
-
import type { Rule, Finding, ScanResult, ScanOptions, RuleCategory, Severity } from '../types.js';
|
|
5
|
+
import type { Rule, Finding, ScanResult, ScanOptions, RuleCategory, Severity, CapacitorConfig } from '../types.js';
|
|
6
|
+
import type * as t from '@babel/types';
|
|
4
7
|
|
|
5
8
|
const DEFAULT_EXCLUDE = [
|
|
6
9
|
'**/node_modules/**',
|
|
@@ -52,8 +55,11 @@ export class SecurityScanner {
|
|
|
52
55
|
const startTime = Date.now();
|
|
53
56
|
const findings: Finding[] = [];
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
const configDetails = await this.getCapacitorConfigDetails();
|
|
59
|
+
|
|
60
|
+
// Get all files to scan (include native projects referenced from capacitor.config)
|
|
61
|
+
const files = await this.getFiles(configDetails);
|
|
62
|
+
const scanContext = this.buildScanContext(files, configDetails);
|
|
57
63
|
|
|
58
64
|
// Process each file
|
|
59
65
|
for (const file of files) {
|
|
@@ -68,12 +74,53 @@ export class SecurityScanner {
|
|
|
68
74
|
timestamp: new Date().toISOString(),
|
|
69
75
|
duration,
|
|
70
76
|
filesScanned: files.length,
|
|
77
|
+
scanContext,
|
|
71
78
|
findings: this.sortFindings(findings),
|
|
72
79
|
summary: this.generateSummary(findings)
|
|
73
80
|
};
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
private
|
|
83
|
+
private buildScanContext(
|
|
84
|
+
files: string[],
|
|
85
|
+
configDetails: {
|
|
86
|
+
configFiles: string[];
|
|
87
|
+
configUsed?: string;
|
|
88
|
+
platformPaths?: {
|
|
89
|
+
android?: { configured?: string; resolved?: string };
|
|
90
|
+
ios?: { configured?: string; resolved?: string };
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
) {
|
|
94
|
+
const norm = (p: string) => p.replace(/\\/g, '/');
|
|
95
|
+
const normalized = files.map(norm);
|
|
96
|
+
|
|
97
|
+
const capacitorConfigFiles = configDetails.configFiles.map(norm);
|
|
98
|
+
const androidManifestFiles = normalized.filter(p =>
|
|
99
|
+
/\/AndroidManifest\.xml$/i.test(p)
|
|
100
|
+
);
|
|
101
|
+
const androidNetworkSecurityConfigFiles = normalized.filter(p =>
|
|
102
|
+
/\/network_security_config\.xml$/i.test(p)
|
|
103
|
+
);
|
|
104
|
+
const iosInfoPlistFiles = normalized.filter(p =>
|
|
105
|
+
/\/Info\.plist$/i.test(p)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
capacitorConfigFiles,
|
|
110
|
+
capacitorConfigUsed: configDetails.configUsed ? norm(configDetails.configUsed) : undefined,
|
|
111
|
+
platformPaths: configDetails.platformPaths,
|
|
112
|
+
androidManifestFiles,
|
|
113
|
+
androidNetworkSecurityConfigFiles,
|
|
114
|
+
iosInfoPlistFiles
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async getFiles(configDetails: {
|
|
119
|
+
platformPaths?: {
|
|
120
|
+
android?: { configured?: string; resolved?: string };
|
|
121
|
+
ios?: { configured?: string; resolved?: string };
|
|
122
|
+
};
|
|
123
|
+
}): Promise<string[]> {
|
|
77
124
|
const excludePatterns = [...DEFAULT_EXCLUDE, ...(this.options.exclude || [])];
|
|
78
125
|
|
|
79
126
|
// Collect all unique file patterns from rules
|
|
@@ -96,6 +143,26 @@ export class SecurityScanner {
|
|
|
96
143
|
patterns.add('**/Info.plist');
|
|
97
144
|
}
|
|
98
145
|
|
|
146
|
+
// If capacitor.config specifies custom native project locations, prefer scanning those.
|
|
147
|
+
// This matters for monorepos where native projects live outside the scanned folder.
|
|
148
|
+
const androidConfigured = configDetails.platformPaths?.android?.configured;
|
|
149
|
+
const iosConfigured = configDetails.platformPaths?.ios?.configured;
|
|
150
|
+
|
|
151
|
+
if (androidConfigured) {
|
|
152
|
+
for (const p of Array.from(patterns)) {
|
|
153
|
+
if (/AndroidManifest\.xml$/i.test(p) || /network_security_config\.xml$/i.test(p)) patterns.delete(p);
|
|
154
|
+
}
|
|
155
|
+
patterns.add(`${this.normalizeGlobPrefix(androidConfigured)}/**/AndroidManifest.xml`);
|
|
156
|
+
patterns.add(`${this.normalizeGlobPrefix(androidConfigured)}/**/network_security_config.xml`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (iosConfigured) {
|
|
160
|
+
for (const p of Array.from(patterns)) {
|
|
161
|
+
if (/Info\.plist$/i.test(p)) patterns.delete(p);
|
|
162
|
+
}
|
|
163
|
+
patterns.add(`${this.normalizeGlobPrefix(iosConfigured)}/**/Info.plist`);
|
|
164
|
+
}
|
|
165
|
+
|
|
99
166
|
const files = await fg(Array.from(patterns), {
|
|
100
167
|
cwd: this.options.path,
|
|
101
168
|
ignore: excludePatterns,
|
|
@@ -111,6 +178,8 @@ export class SecurityScanner {
|
|
|
111
178
|
|
|
112
179
|
try {
|
|
113
180
|
const content = await Bun.file(filePath).text();
|
|
181
|
+
const relPath = this.normalizePath(path.relative(this.options.path, filePath));
|
|
182
|
+
const absPath = this.normalizePath(filePath);
|
|
114
183
|
|
|
115
184
|
// Get rules that match this file
|
|
116
185
|
const applicableRules = this.rules.filter(rule => {
|
|
@@ -122,7 +191,11 @@ export class SecurityScanner {
|
|
|
122
191
|
.replace(/\./g, '\\.')
|
|
123
192
|
.replace(/\*\*/g, '.*')
|
|
124
193
|
.replace(/(?<!\.)(\*)/g, '[^/]*');
|
|
125
|
-
|
|
194
|
+
const rx = new RegExp(regexPattern);
|
|
195
|
+
// Match against both relative and absolute paths.
|
|
196
|
+
// Relative paths preserve leading ../ segments for monorepos, which is important when
|
|
197
|
+
// filePatterns come from capacitor.config android.path / ios.path.
|
|
198
|
+
return rx.test(relPath) || rx.test(absPath);
|
|
126
199
|
});
|
|
127
200
|
});
|
|
128
201
|
|
|
@@ -165,6 +238,169 @@ export class SecurityScanner {
|
|
|
165
238
|
return findings;
|
|
166
239
|
}
|
|
167
240
|
|
|
241
|
+
private normalizePath(p: string): string {
|
|
242
|
+
return p.replace(/\\/g, '/');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private normalizeGlobPrefix(p: string): string {
|
|
246
|
+
// fast-glob expects POSIX separators in patterns; keep ../ segments intact.
|
|
247
|
+
return this.normalizePath(p).replace(/\/+$/, '');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async getCapacitorConfigDetails(): Promise<{
|
|
251
|
+
configFiles: string[];
|
|
252
|
+
configUsed?: string;
|
|
253
|
+
platformPaths?: {
|
|
254
|
+
android?: { configured?: string; resolved?: string };
|
|
255
|
+
ios?: { configured?: string; resolved?: string };
|
|
256
|
+
};
|
|
257
|
+
}> {
|
|
258
|
+
const excludePatterns = [...DEFAULT_EXCLUDE, ...(this.options.exclude || [])];
|
|
259
|
+
|
|
260
|
+
const configFiles = await fg(['**/capacitor.config.*'], {
|
|
261
|
+
cwd: this.options.path,
|
|
262
|
+
ignore: excludePatterns,
|
|
263
|
+
absolute: true,
|
|
264
|
+
onlyFiles: true
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const configUsed = this.pickPreferredCapacitorConfig(configFiles);
|
|
268
|
+
if (!configUsed) return { configFiles };
|
|
269
|
+
|
|
270
|
+
const cfg = await this.parseCapacitorConfig(configUsed);
|
|
271
|
+
const androidPath = typeof cfg?.android?.path === 'string' ? cfg.android.path : undefined;
|
|
272
|
+
const iosPath = typeof cfg?.ios?.path === 'string' ? cfg.ios.path : undefined;
|
|
273
|
+
|
|
274
|
+
const platformPaths = (androidPath || iosPath) ? {
|
|
275
|
+
android: androidPath ? { configured: this.normalizeGlobPrefix(androidPath), resolved: path.resolve(this.options.path, androidPath) } : undefined,
|
|
276
|
+
ios: iosPath ? { configured: this.normalizeGlobPrefix(iosPath), resolved: path.resolve(this.options.path, iosPath) } : undefined
|
|
277
|
+
} : undefined;
|
|
278
|
+
|
|
279
|
+
return { configFiles, configUsed, platformPaths };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private pickPreferredCapacitorConfig(files: string[]): string | undefined {
|
|
283
|
+
if (!files || files.length === 0) return undefined;
|
|
284
|
+
const byName = (name: string) => files.find(f => this.normalizePath(f).toLowerCase().endsWith(`/${name}`));
|
|
285
|
+
return (
|
|
286
|
+
byName('capacitor.config.ts') ??
|
|
287
|
+
byName('capacitor.config.js') ??
|
|
288
|
+
byName('capacitor.config.mjs') ??
|
|
289
|
+
byName('capacitor.config.cjs') ??
|
|
290
|
+
byName('capacitor.config.json') ??
|
|
291
|
+
files[0]
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async parseCapacitorConfig(filePath: string): Promise<CapacitorConfig | undefined> {
|
|
296
|
+
try {
|
|
297
|
+
const content = await Bun.file(filePath).text();
|
|
298
|
+
const lower = filePath.toLowerCase();
|
|
299
|
+
|
|
300
|
+
if (lower.endsWith('.json')) {
|
|
301
|
+
return JSON.parse(content) as CapacitorConfig;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// For TS/JS configs, parse the module and extract the exported object literal when possible.
|
|
305
|
+
const ast = parse(content, {
|
|
306
|
+
sourceType: 'module',
|
|
307
|
+
plugins: ['typescript', 'jsx']
|
|
308
|
+
}) as unknown as t.File;
|
|
309
|
+
|
|
310
|
+
const env = this.collectTopLevelBindings(ast);
|
|
311
|
+
const exported = this.findDefaultExport(ast);
|
|
312
|
+
const value = this.evalConfigExpression(exported, env);
|
|
313
|
+
|
|
314
|
+
return (value && typeof value === 'object') ? (value as CapacitorConfig) : undefined;
|
|
315
|
+
} catch (e) {
|
|
316
|
+
if (this.options.verbose) console.error(`Failed to parse capacitor config at ${filePath}:`, e);
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private collectTopLevelBindings(ast: t.File): Record<string, t.Expression> {
|
|
322
|
+
const env: Record<string, t.Expression> = {};
|
|
323
|
+
for (const stmt of ast.program.body) {
|
|
324
|
+
if (stmt.type !== 'VariableDeclaration') continue;
|
|
325
|
+
for (const decl of stmt.declarations) {
|
|
326
|
+
if (decl.id.type !== 'Identifier') continue;
|
|
327
|
+
if (!decl.init) continue;
|
|
328
|
+
// Only store expressions we can potentially evaluate later.
|
|
329
|
+
if (decl.init.type.endsWith('Expression') || decl.init.type.endsWith('Literal') || decl.init.type === 'ObjectExpression' || decl.init.type === 'ArrayExpression' || decl.init.type === 'Identifier') {
|
|
330
|
+
env[decl.id.name] = decl.init as t.Expression;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return env;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private findDefaultExport(ast: t.File): t.Expression | undefined {
|
|
338
|
+
for (const stmt of ast.program.body) {
|
|
339
|
+
if (stmt.type === 'ExportDefaultDeclaration') {
|
|
340
|
+
const d = stmt.declaration;
|
|
341
|
+
// Could be an expression or a declaration; we only handle expressions/calls/identifiers.
|
|
342
|
+
if (d.type === 'Identifier') return d;
|
|
343
|
+
if (d.type === 'ObjectExpression') return d;
|
|
344
|
+
if (d.type === 'CallExpression') return d;
|
|
345
|
+
if (d.type.endsWith('Expression')) return d as unknown as t.Expression;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private evalConfigExpression(
|
|
352
|
+
expr: t.Expression | undefined,
|
|
353
|
+
env: Record<string, t.Expression>,
|
|
354
|
+
depth = 0
|
|
355
|
+
): any {
|
|
356
|
+
if (!expr) return undefined;
|
|
357
|
+
if (depth > 5) return undefined;
|
|
358
|
+
|
|
359
|
+
switch (expr.type) {
|
|
360
|
+
case 'ObjectExpression': {
|
|
361
|
+
const out: Record<string, any> = {};
|
|
362
|
+
for (const prop of expr.properties) {
|
|
363
|
+
if (prop.type !== 'ObjectProperty') continue;
|
|
364
|
+
const key = this.objectKeyToString(prop.key);
|
|
365
|
+
if (!key) continue;
|
|
366
|
+
out[key] = this.evalConfigExpression(prop.value as t.Expression, env, depth + 1);
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
case 'ArrayExpression':
|
|
371
|
+
return expr.elements.map(el => (el && el.type !== 'SpreadElement') ? this.evalConfigExpression(el as t.Expression, env, depth + 1) : undefined);
|
|
372
|
+
case 'StringLiteral':
|
|
373
|
+
return expr.value;
|
|
374
|
+
case 'BooleanLiteral':
|
|
375
|
+
return expr.value;
|
|
376
|
+
case 'NumericLiteral':
|
|
377
|
+
return expr.value;
|
|
378
|
+
case 'NullLiteral':
|
|
379
|
+
return null;
|
|
380
|
+
case 'Identifier': {
|
|
381
|
+
const bound = env[expr.name];
|
|
382
|
+
if (!bound) return undefined;
|
|
383
|
+
return this.evalConfigExpression(bound, env, depth + 1);
|
|
384
|
+
}
|
|
385
|
+
case 'CallExpression': {
|
|
386
|
+
// Handle defineConfig({ ... }) and similar wrappers.
|
|
387
|
+
const arg0 = expr.arguments[0];
|
|
388
|
+
if (arg0 && arg0.type !== 'SpreadElement' && (arg0.type === 'ObjectExpression' || arg0.type === 'Identifier')) {
|
|
389
|
+
return this.evalConfigExpression(arg0 as t.Expression, env, depth + 1);
|
|
390
|
+
}
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
default:
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private objectKeyToString(key: t.ObjectProperty['key']): string | undefined {
|
|
399
|
+
if (key.type === 'Identifier') return key.name;
|
|
400
|
+
if (key.type === 'StringLiteral') return key.value;
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
168
404
|
private sortFindings(findings: Finding[]): Finding[] {
|
|
169
405
|
const severityOrder: Record<Severity, number> = {
|
|
170
406
|
critical: 0,
|
package/src/types.ts
CHANGED
|
@@ -47,6 +47,17 @@ export interface ScanResult {
|
|
|
47
47
|
timestamp: string;
|
|
48
48
|
duration: number;
|
|
49
49
|
filesScanned: number;
|
|
50
|
+
scanContext?: {
|
|
51
|
+
capacitorConfigFiles: string[];
|
|
52
|
+
capacitorConfigUsed?: string;
|
|
53
|
+
platformPaths?: {
|
|
54
|
+
android?: { configured?: string; resolved?: string };
|
|
55
|
+
ios?: { configured?: string; resolved?: string };
|
|
56
|
+
};
|
|
57
|
+
androidManifestFiles: string[];
|
|
58
|
+
androidNetworkSecurityConfigFiles: string[];
|
|
59
|
+
iosInfoPlistFiles: string[];
|
|
60
|
+
};
|
|
50
61
|
findings: Finding[];
|
|
51
62
|
summary: {
|
|
52
63
|
total: number;
|
|
@@ -77,12 +88,14 @@ export interface CapacitorConfig {
|
|
|
77
88
|
webDir?: string;
|
|
78
89
|
plugins?: Record<string, any>;
|
|
79
90
|
android?: {
|
|
91
|
+
path?: string;
|
|
80
92
|
allowMixedContent?: boolean;
|
|
81
93
|
captureInput?: boolean;
|
|
82
94
|
webContentsDebuggingEnabled?: boolean;
|
|
83
95
|
loggingBehavior?: string;
|
|
84
96
|
};
|
|
85
97
|
ios?: {
|
|
98
|
+
path?: string;
|
|
86
99
|
allowsLinkPreview?: boolean;
|
|
87
100
|
scrollEnabled?: boolean;
|
|
88
101
|
webContentsDebuggingEnabled?: boolean;
|
package/src/utils/reporter.ts
CHANGED
|
@@ -28,6 +28,22 @@ export function formatCliReport(result: ScanResult): string {
|
|
|
28
28
|
lines.push(`Project: ${result.projectPath}`);
|
|
29
29
|
lines.push(`Scanned: ${result.filesScanned} files in ${result.duration}ms`);
|
|
30
30
|
lines.push(`Time: ${result.timestamp}`);
|
|
31
|
+
if (result.scanContext) {
|
|
32
|
+
lines.push('');
|
|
33
|
+
lines.push(`${BOLD}Scan Context${RESET}`);
|
|
34
|
+
lines.push(`${'─'.repeat(60)}`);
|
|
35
|
+
const used = result.scanContext.capacitorConfigUsed ? ` (used: ${result.scanContext.capacitorConfigUsed})` : '';
|
|
36
|
+
lines.push(`Capacitor config: ${formatPathList(result.scanContext.capacitorConfigFiles)}${used}`);
|
|
37
|
+
if (result.scanContext.platformPaths?.android) {
|
|
38
|
+
lines.push(`Android path: ${formatConfiguredResolved(result.scanContext.platformPaths.android)}`);
|
|
39
|
+
}
|
|
40
|
+
if (result.scanContext.platformPaths?.ios) {
|
|
41
|
+
lines.push(`iOS path: ${formatConfiguredResolved(result.scanContext.platformPaths.ios)}`);
|
|
42
|
+
}
|
|
43
|
+
lines.push(`Android manifest: ${formatPathList(result.scanContext.androidManifestFiles)}`);
|
|
44
|
+
lines.push(`Android netsec: ${formatPathList(result.scanContext.androidNetworkSecurityConfigFiles)}`);
|
|
45
|
+
lines.push(`iOS Info.plist: ${formatPathList(result.scanContext.iosInfoPlistFiles)}`);
|
|
46
|
+
}
|
|
31
47
|
lines.push('');
|
|
32
48
|
|
|
33
49
|
// Severity breakdown
|
|
@@ -84,6 +100,18 @@ export function formatCliReport(result: ScanResult): string {
|
|
|
84
100
|
return lines.join('\n');
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
function formatPathList(paths: string[]): string {
|
|
104
|
+
if (!paths || paths.length === 0) return 'not found';
|
|
105
|
+
if (paths.length <= 2) return paths.join(', ');
|
|
106
|
+
return `${paths.slice(0, 2).join(', ')} (+${paths.length - 2} more)`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatConfiguredResolved(v: { configured?: string; resolved?: string }): string {
|
|
110
|
+
const c = v.configured ?? 'n/a';
|
|
111
|
+
const r = v.resolved ?? 'n/a';
|
|
112
|
+
return `${c} -> ${r}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
function formatFinding(finding: Finding): string {
|
|
88
116
|
const lines: string[] = [];
|
|
89
117
|
const severityColor = SEVERITY_COLORS[finding.severity];
|
package/test/rules.test.ts
CHANGED
|
@@ -98,6 +98,21 @@ describe('Security Rules', () => {
|
|
|
98
98
|
expect(findings.length).toBe(0);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
test('network rules should detect capacitor cleartext in JSON config', () => {
|
|
102
|
+
const rule = networkRules.find(r => r.id === 'NET003');
|
|
103
|
+
expect(rule).toBeDefined();
|
|
104
|
+
|
|
105
|
+
const testJson = `{
|
|
106
|
+
"server": {
|
|
107
|
+
"cleartext": true
|
|
108
|
+
}
|
|
109
|
+
}`;
|
|
110
|
+
const findings = rule!.check!(testJson, 'capacitor.config.json');
|
|
111
|
+
|
|
112
|
+
expect(findings.length).toBeGreaterThan(0);
|
|
113
|
+
expect(findings[0].severity).toBe('critical');
|
|
114
|
+
});
|
|
115
|
+
|
|
101
116
|
test('capacitor rules should detect WebView debug mode', () => {
|
|
102
117
|
const rule = capacitorRules.find(r => r.id === 'CAP001');
|
|
103
118
|
expect(rule).toBeDefined();
|
package/test/scanner.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
|
2
2
|
import { SecurityScanner } from '../src/scanners/engine';
|
|
3
|
-
import { mkdir, rm } from 'fs/promises';
|
|
3
|
+
import { mkdir, rm, mkdtemp } from 'fs/promises';
|
|
4
4
|
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
5
6
|
|
|
6
7
|
const TEST_DIR = '/tmp/capsec-test-project';
|
|
7
8
|
|
|
@@ -289,4 +290,73 @@ export async function saveCredentials(password: string) {
|
|
|
289
290
|
const count = SecurityScanner.getRuleCount();
|
|
290
291
|
expect(count).toBeGreaterThan(60);
|
|
291
292
|
});
|
|
293
|
+
|
|
294
|
+
test('should scan native projects using capacitor config paths (monorepo)', async () => {
|
|
295
|
+
const root = await mkdtemp(join(tmpdir(), 'capsec-monorepo-'));
|
|
296
|
+
const uiDir = join(root, 'apps', 'ui');
|
|
297
|
+
const androidDir = join(root, 'native', 'android');
|
|
298
|
+
const iosDir = join(root, 'native', 'ios', 'App');
|
|
299
|
+
|
|
300
|
+
await mkdir(join(uiDir, 'src'), { recursive: true });
|
|
301
|
+
await mkdir(join(androidDir, 'app', 'src', 'main'), { recursive: true });
|
|
302
|
+
await mkdir(iosDir, { recursive: true });
|
|
303
|
+
|
|
304
|
+
// Point to native projects outside the UI folder.
|
|
305
|
+
await Bun.write(
|
|
306
|
+
join(uiDir, 'capacitor.config.ts'),
|
|
307
|
+
`
|
|
308
|
+
export default {
|
|
309
|
+
appId: 'com.example.monorepo',
|
|
310
|
+
appName: 'Monorepo Test',
|
|
311
|
+
android: { path: '../../native/android' },
|
|
312
|
+
ios: { path: '../../native/ios/App' },
|
|
313
|
+
server: { cleartext: true }
|
|
314
|
+
};
|
|
315
|
+
`
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
await Bun.write(
|
|
319
|
+
join(androidDir, 'app', 'src', 'main', 'AndroidManifest.xml'),
|
|
320
|
+
`
|
|
321
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
322
|
+
<application android:usesCleartextTraffic="true" android:debuggable="true" />
|
|
323
|
+
</manifest>
|
|
324
|
+
`
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await Bun.write(
|
|
328
|
+
join(iosDir, 'Info.plist'),
|
|
329
|
+
`
|
|
330
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
331
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN">
|
|
332
|
+
<plist version="1.0">
|
|
333
|
+
<dict>
|
|
334
|
+
<key>NSAppTransportSecurity</key>
|
|
335
|
+
<dict>
|
|
336
|
+
<key>NSAllowsArbitraryLoads</key>
|
|
337
|
+
<true />
|
|
338
|
+
</dict>
|
|
339
|
+
</dict>
|
|
340
|
+
</plist>
|
|
341
|
+
`
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const scanner = new SecurityScanner({ path: uiDir });
|
|
345
|
+
const result = await scanner.scan();
|
|
346
|
+
|
|
347
|
+
// It should discover and scan files outside uiDir via android.path / ios.path.
|
|
348
|
+
expect(result.filesScanned).toBeGreaterThan(0);
|
|
349
|
+
expect(result.scanContext?.capacitorConfigUsed).toContain('capacitor.config.ts');
|
|
350
|
+
expect(result.scanContext?.androidManifestFiles.some(p => p.includes('/native/android/'))).toBe(true);
|
|
351
|
+
expect(result.scanContext?.iosInfoPlistFiles.some(p => p.includes('/native/ios/'))).toBe(true);
|
|
352
|
+
|
|
353
|
+
// And it should flag the expected misconfigurations.
|
|
354
|
+
const ids = new Set(result.findings.map(f => f.ruleId));
|
|
355
|
+
expect(ids.has('AND001')).toBe(true);
|
|
356
|
+
expect(ids.has('AND002')).toBe(true);
|
|
357
|
+
expect(ids.has('IOS001')).toBe(true);
|
|
358
|
+
expect(ids.has('NET003')).toBe(true);
|
|
359
|
+
|
|
360
|
+
await rm(root, { recursive: true, force: true });
|
|
361
|
+
});
|
|
292
362
|
});
|