@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capgo-sec",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "description": "Security scanner for Capacitor apps - detect vulnerabilities, hardcoded secrets, and security misconfigurations",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- filePatterns: ['**/capacitor.config.ts', '**/capacitor.config.js', '**/capacitor.config.json'],
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
- const cleartextPattern = /cleartext\s*:\s*true/i;
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);
@@ -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
- // Get all files to scan
56
- const files = await this.getFiles();
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 async getFiles(): Promise<string[]> {
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
- return new RegExp(regexPattern).test(filePath);
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;
@@ -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];
@@ -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();
@@ -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
  });