@capgo/capgo-sec 1.0.4
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/.github/workflows/bump_version.yml +83 -0
- package/.github/workflows/ci.yml +44 -0
- package/AGENTS.md +125 -0
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/bun.lock +146 -0
- package/dist/cli/index.js +13248 -0
- package/dist/index.js +8273 -0
- package/package.json +53 -0
- package/renovate.json +39 -0
- package/src/cli/index.ts +183 -0
- package/src/index.ts +31 -0
- package/src/rules/android.ts +392 -0
- package/src/rules/authentication.ts +261 -0
- package/src/rules/capacitor.ts +435 -0
- package/src/rules/cryptography.ts +190 -0
- package/src/rules/index.ts +56 -0
- package/src/rules/ios.ts +326 -0
- package/src/rules/logging.ts +218 -0
- package/src/rules/network.ts +310 -0
- package/src/rules/secrets.ts +163 -0
- package/src/rules/storage.ts +241 -0
- package/src/rules/webview.ts +232 -0
- package/src/scanners/engine.ts +233 -0
- package/src/types.ts +96 -0
- package/src/utils/reporter.ts +209 -0
- package/test/rules.test.ts +235 -0
- package/test/scanner.test.ts +292 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { Rule, Finding } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export const storageRules: Rule[] = [
|
|
4
|
+
{
|
|
5
|
+
id: 'STO001',
|
|
6
|
+
name: 'Unencrypted Sensitive Data in Preferences',
|
|
7
|
+
description: 'Detects storage of sensitive data in Capacitor Preferences without encryption',
|
|
8
|
+
severity: 'high',
|
|
9
|
+
category: 'storage',
|
|
10
|
+
filePatterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
|
11
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
12
|
+
const findings: Finding[] = [];
|
|
13
|
+
const lines = content.split('\n');
|
|
14
|
+
|
|
15
|
+
// Pattern for Preferences.set with sensitive keys
|
|
16
|
+
const sensitiveKeys = /(?:password|token|secret|key|auth|session|credential|pin|ssn|credit.?card)/i;
|
|
17
|
+
const preferencesPattern = /Preferences\.set\s*\(\s*\{[^}]*key:\s*['"]([^'"]+)['"]/g;
|
|
18
|
+
|
|
19
|
+
let match;
|
|
20
|
+
while ((match = preferencesPattern.exec(content)) !== null) {
|
|
21
|
+
const keyName = match[1];
|
|
22
|
+
if (sensitiveKeys.test(keyName)) {
|
|
23
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
24
|
+
findings.push({
|
|
25
|
+
ruleId: 'STO001',
|
|
26
|
+
ruleName: 'Unencrypted Sensitive Data in Preferences',
|
|
27
|
+
severity: 'high',
|
|
28
|
+
category: 'storage',
|
|
29
|
+
message: `Sensitive data "${keyName}" stored in Preferences without encryption`,
|
|
30
|
+
filePath,
|
|
31
|
+
line: lineNum,
|
|
32
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
33
|
+
remediation: 'Use @capgo/capacitor-native-biometric or iOS Keychain/Android Keystore for sensitive data storage.',
|
|
34
|
+
references: [
|
|
35
|
+
'https://capacitor-sec.dev/docs/rules/storage',
|
|
36
|
+
'https://capacitorjs.com/docs/apis/preferences'
|
|
37
|
+
]
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return findings;
|
|
43
|
+
},
|
|
44
|
+
remediation: 'Use secure storage solutions like @capgo/capacitor-native-biometric for sensitive data.'
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'STO002',
|
|
48
|
+
name: 'localStorage Usage for Sensitive Data',
|
|
49
|
+
description: 'Detects usage of localStorage for storing sensitive information',
|
|
50
|
+
severity: 'high',
|
|
51
|
+
category: 'storage',
|
|
52
|
+
filePatterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
|
53
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
54
|
+
const findings: Finding[] = [];
|
|
55
|
+
const lines = content.split('\n');
|
|
56
|
+
|
|
57
|
+
const sensitiveKeys = /(?:password|token|secret|key|auth|session|credential|pin|ssn|credit.?card|jwt|bearer)/i;
|
|
58
|
+
const localStoragePattern = /localStorage\.setItem\s*\(\s*['"]([^'"]+)['"]/g;
|
|
59
|
+
|
|
60
|
+
let match;
|
|
61
|
+
while ((match = localStoragePattern.exec(content)) !== null) {
|
|
62
|
+
const keyName = match[1];
|
|
63
|
+
if (sensitiveKeys.test(keyName)) {
|
|
64
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
65
|
+
findings.push({
|
|
66
|
+
ruleId: 'STO002',
|
|
67
|
+
ruleName: 'localStorage Usage for Sensitive Data',
|
|
68
|
+
severity: 'high',
|
|
69
|
+
category: 'storage',
|
|
70
|
+
message: `Sensitive data "${keyName}" stored in localStorage which is not secure`,
|
|
71
|
+
filePath,
|
|
72
|
+
line: lineNum,
|
|
73
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
74
|
+
remediation: 'Use Capacitor Preferences with encryption or secure native storage instead of localStorage.',
|
|
75
|
+
references: ['https://capacitor-sec.dev/docs/rules/localstorage']
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return findings;
|
|
81
|
+
},
|
|
82
|
+
remediation: 'Avoid localStorage for sensitive data. Use Capacitor\'s secure storage APIs.'
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'STO003',
|
|
86
|
+
name: 'SQLite Database Without Encryption',
|
|
87
|
+
description: 'Detects SQLite database usage without encryption enabled',
|
|
88
|
+
severity: 'medium',
|
|
89
|
+
category: 'storage',
|
|
90
|
+
filePatterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
|
91
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
92
|
+
const findings: Finding[] = [];
|
|
93
|
+
const lines = content.split('\n');
|
|
94
|
+
|
|
95
|
+
// Check for SQLite plugin without encryption
|
|
96
|
+
const sqlitePattern = /(?:CapacitorSQLite|SQLite)\.(?:createConnection|open)\s*\(/g;
|
|
97
|
+
const hasEncryption = /encrypted:\s*true|secret:\s*['"][^'"]+['"]/;
|
|
98
|
+
|
|
99
|
+
let match;
|
|
100
|
+
while ((match = sqlitePattern.exec(content)) !== null) {
|
|
101
|
+
// Look for encryption in the surrounding context (100 chars)
|
|
102
|
+
const context = content.substring(Math.max(0, match.index - 50), match.index + 200);
|
|
103
|
+
if (!hasEncryption.test(context)) {
|
|
104
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
105
|
+
findings.push({
|
|
106
|
+
ruleId: 'STO003',
|
|
107
|
+
ruleName: 'SQLite Database Without Encryption',
|
|
108
|
+
severity: 'medium',
|
|
109
|
+
category: 'storage',
|
|
110
|
+
message: 'SQLite database created without encryption enabled',
|
|
111
|
+
filePath,
|
|
112
|
+
line: lineNum,
|
|
113
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
114
|
+
remediation: 'Enable SQLite encryption using the encrypted option and provide a secure encryption key.',
|
|
115
|
+
references: ['https://github.com/capacitor-community/sqlite']
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return findings;
|
|
121
|
+
},
|
|
122
|
+
remediation: 'Enable SQLCipher encryption for SQLite databases containing sensitive data.'
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'STO004',
|
|
126
|
+
name: 'Filesystem Storage of Sensitive Data',
|
|
127
|
+
description: 'Detects writing sensitive data to the filesystem without encryption',
|
|
128
|
+
severity: 'high',
|
|
129
|
+
category: 'storage',
|
|
130
|
+
filePatterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
|
131
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
132
|
+
const findings: Finding[] = [];
|
|
133
|
+
const lines = content.split('\n');
|
|
134
|
+
|
|
135
|
+
const writePattern = /Filesystem\.writeFile\s*\(/g;
|
|
136
|
+
const sensitiveContent = /(?:password|token|secret|key|auth|credential|private)/i;
|
|
137
|
+
|
|
138
|
+
let match;
|
|
139
|
+
while ((match = writePattern.exec(content)) !== null) {
|
|
140
|
+
const context = content.substring(match.index, Math.min(content.length, match.index + 300));
|
|
141
|
+
if (sensitiveContent.test(context)) {
|
|
142
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
143
|
+
findings.push({
|
|
144
|
+
ruleId: 'STO004',
|
|
145
|
+
ruleName: 'Filesystem Storage of Sensitive Data',
|
|
146
|
+
severity: 'high',
|
|
147
|
+
category: 'storage',
|
|
148
|
+
message: 'Potentially sensitive data written to filesystem without encryption',
|
|
149
|
+
filePath,
|
|
150
|
+
line: lineNum,
|
|
151
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
152
|
+
remediation: 'Encrypt sensitive data before writing to filesystem. Consider using secure storage alternatives.',
|
|
153
|
+
references: ['https://capacitorjs.com/docs/apis/filesystem']
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return findings;
|
|
159
|
+
},
|
|
160
|
+
remediation: 'Encrypt data before filesystem storage or use secure storage APIs.'
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: 'STO005',
|
|
164
|
+
name: 'Insecure Data Caching',
|
|
165
|
+
description: 'Detects caching of sensitive data that could persist beyond session',
|
|
166
|
+
severity: 'medium',
|
|
167
|
+
category: 'storage',
|
|
168
|
+
filePatterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
|
169
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
170
|
+
const findings: Finding[] = [];
|
|
171
|
+
const lines = content.split('\n');
|
|
172
|
+
|
|
173
|
+
// Check for various caching patterns with sensitive data
|
|
174
|
+
const cachePatterns = [
|
|
175
|
+
/sessionStorage\.setItem\s*\(\s*['"](?:[^'"]*(?:token|auth|session|credential)[^'"]*)['"]/gi,
|
|
176
|
+
/\.cache\s*\(\s*['"](?:[^'"]*(?:token|auth|user|session)[^'"]*)['"]/gi,
|
|
177
|
+
/IndexedDB.*(?:token|auth|password|credential)/gi
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
for (const pattern of cachePatterns) {
|
|
181
|
+
let match;
|
|
182
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
183
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
184
|
+
findings.push({
|
|
185
|
+
ruleId: 'STO005',
|
|
186
|
+
ruleName: 'Insecure Data Caching',
|
|
187
|
+
severity: 'medium',
|
|
188
|
+
category: 'storage',
|
|
189
|
+
message: 'Sensitive data may be cached insecurely',
|
|
190
|
+
filePath,
|
|
191
|
+
line: lineNum,
|
|
192
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
193
|
+
remediation: 'Avoid caching sensitive data. If necessary, use encrypted storage with proper expiration.',
|
|
194
|
+
references: ['https://capacitor-sec.dev/docs/rules/caching']
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return findings;
|
|
200
|
+
},
|
|
201
|
+
remediation: 'Implement secure caching with encryption and proper data expiration.'
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: 'STO006',
|
|
205
|
+
name: 'Keychain/Keystore Not Used for Credentials',
|
|
206
|
+
description: 'Detects credential storage that should use native secure storage',
|
|
207
|
+
severity: 'high',
|
|
208
|
+
category: 'storage',
|
|
209
|
+
filePatterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
|
210
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
211
|
+
const findings: Finding[] = [];
|
|
212
|
+
const lines = content.split('\n');
|
|
213
|
+
|
|
214
|
+
// Check for credential storage without using native biometric/secure storage
|
|
215
|
+
const insecureStoragePattern = /(?:Preferences|localStorage|sessionStorage).*(?:password|credential|biometric|pin)/gi;
|
|
216
|
+
|
|
217
|
+
let match;
|
|
218
|
+
while ((match = insecureStoragePattern.exec(content)) !== null) {
|
|
219
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
220
|
+
findings.push({
|
|
221
|
+
ruleId: 'STO006',
|
|
222
|
+
ruleName: 'Keychain/Keystore Not Used for Credentials',
|
|
223
|
+
severity: 'high',
|
|
224
|
+
category: 'storage',
|
|
225
|
+
message: 'Credentials should be stored in iOS Keychain or Android Keystore',
|
|
226
|
+
filePath,
|
|
227
|
+
line: lineNum,
|
|
228
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
229
|
+
remediation: 'Use @capgo/capacitor-native-biometric or similar plugin for secure credential storage.',
|
|
230
|
+
references: [
|
|
231
|
+
'https://github.com/nickcox/capacitor-native-biometric',
|
|
232
|
+
'https://capacitor-sec.dev/docs/rules/secure-storage'
|
|
233
|
+
]
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return findings;
|
|
238
|
+
},
|
|
239
|
+
remediation: 'Use iOS Keychain and Android Keystore through appropriate Capacitor plugins.'
|
|
240
|
+
}
|
|
241
|
+
];
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { Rule, Finding } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export const webviewRules: Rule[] = [
|
|
4
|
+
{
|
|
5
|
+
id: 'WEB001',
|
|
6
|
+
name: 'WebView JavaScript Injection',
|
|
7
|
+
description: 'Detects potential JavaScript injection vulnerabilities in WebView',
|
|
8
|
+
severity: 'critical',
|
|
9
|
+
category: 'webview',
|
|
10
|
+
filePatterns: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.html'],
|
|
11
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
12
|
+
const findings: Finding[] = [];
|
|
13
|
+
const lines = content.split('\n');
|
|
14
|
+
|
|
15
|
+
// Check for innerHTML with user input
|
|
16
|
+
const dangerousPatterns = [
|
|
17
|
+
{ pattern: /innerHTML\s*=\s*(?!['"`]<)/, message: 'innerHTML assignment with potential user input' },
|
|
18
|
+
{ pattern: /document\.write\s*\(/, message: 'document.write can inject arbitrary content' },
|
|
19
|
+
{ pattern: /outerHTML\s*=\s*(?!['"`]<)/, message: 'outerHTML assignment with potential user input' },
|
|
20
|
+
{ pattern: /insertAdjacentHTML\s*\(/, message: 'insertAdjacentHTML can inject HTML/scripts' }
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
for (const { pattern, message } of dangerousPatterns) {
|
|
24
|
+
let match;
|
|
25
|
+
const regex = new RegExp(pattern.source, 'gi');
|
|
26
|
+
while ((match = regex.exec(content)) !== null) {
|
|
27
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
28
|
+
findings.push({
|
|
29
|
+
ruleId: 'WEB001',
|
|
30
|
+
ruleName: 'WebView JavaScript Injection',
|
|
31
|
+
severity: 'high',
|
|
32
|
+
category: 'webview',
|
|
33
|
+
message,
|
|
34
|
+
filePath,
|
|
35
|
+
line: lineNum,
|
|
36
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
37
|
+
remediation: 'Use textContent for text or create elements with DOM APIs. Sanitize any HTML input.',
|
|
38
|
+
references: [
|
|
39
|
+
'https://owasp.org/www-community/attacks/xss/',
|
|
40
|
+
'https://capacitor-sec.dev/docs/rules/xss'
|
|
41
|
+
]
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return findings;
|
|
47
|
+
},
|
|
48
|
+
remediation: 'Use textContent or sanitize HTML input before insertion.'
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'WEB002',
|
|
52
|
+
name: 'Unsafe iframe Configuration',
|
|
53
|
+
description: 'Detects iframes without proper sandboxing',
|
|
54
|
+
severity: 'high',
|
|
55
|
+
category: 'webview',
|
|
56
|
+
filePatterns: ['**/*.html', '**/*.tsx', '**/*.jsx', '**/*.vue'],
|
|
57
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
58
|
+
const findings: Finding[] = [];
|
|
59
|
+
const lines = content.split('\n');
|
|
60
|
+
|
|
61
|
+
// Check for iframes without sandbox
|
|
62
|
+
const iframePattern = /<iframe[^>]*>/gi;
|
|
63
|
+
|
|
64
|
+
let match;
|
|
65
|
+
while ((match = iframePattern.exec(content)) !== null) {
|
|
66
|
+
const iframeTag = match[0];
|
|
67
|
+
const hasSandbox = /sandbox\s*=/.test(iframeTag);
|
|
68
|
+
const hasAllowScripts = /allow-scripts/.test(iframeTag);
|
|
69
|
+
const hasAllowSameOrigin = /allow-same-origin/.test(iframeTag);
|
|
70
|
+
|
|
71
|
+
if (!hasSandbox) {
|
|
72
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
73
|
+
findings.push({
|
|
74
|
+
ruleId: 'WEB002',
|
|
75
|
+
ruleName: 'Unsafe iframe Configuration',
|
|
76
|
+
severity: 'high',
|
|
77
|
+
category: 'webview',
|
|
78
|
+
message: 'iframe without sandbox attribute',
|
|
79
|
+
filePath,
|
|
80
|
+
line: lineNum,
|
|
81
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
82
|
+
remediation: 'Add sandbox attribute to iframe with minimal required permissions.',
|
|
83
|
+
references: ['https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox']
|
|
84
|
+
});
|
|
85
|
+
} else if (hasAllowScripts && hasAllowSameOrigin) {
|
|
86
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
87
|
+
findings.push({
|
|
88
|
+
ruleId: 'WEB002',
|
|
89
|
+
ruleName: 'Unsafe iframe Configuration',
|
|
90
|
+
severity: 'medium',
|
|
91
|
+
category: 'webview',
|
|
92
|
+
message: 'iframe with allow-scripts and allow-same-origin is nearly equivalent to no sandbox',
|
|
93
|
+
filePath,
|
|
94
|
+
line: lineNum,
|
|
95
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
96
|
+
remediation: 'Avoid combining allow-scripts with allow-same-origin if possible.',
|
|
97
|
+
references: ['https://capacitor-sec.dev/docs/rules/iframe']
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return findings;
|
|
103
|
+
},
|
|
104
|
+
remediation: 'Use sandbox attribute with minimal permissions on iframes.'
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'WEB003',
|
|
108
|
+
name: 'External Script Loading',
|
|
109
|
+
description: 'Detects loading of scripts from external sources without integrity',
|
|
110
|
+
severity: 'medium',
|
|
111
|
+
category: 'webview',
|
|
112
|
+
filePatterns: ['**/*.html', '**/index.html'],
|
|
113
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
114
|
+
const findings: Finding[] = [];
|
|
115
|
+
const lines = content.split('\n');
|
|
116
|
+
|
|
117
|
+
// Check for external scripts without integrity
|
|
118
|
+
const scriptPattern = /<script[^>]*src\s*=\s*['"]https?:\/\/[^'"]+['"][^>]*>/gi;
|
|
119
|
+
|
|
120
|
+
let match;
|
|
121
|
+
while ((match = scriptPattern.exec(content)) !== null) {
|
|
122
|
+
const scriptTag = match[0];
|
|
123
|
+
const hasIntegrity = /integrity\s*=/.test(scriptTag);
|
|
124
|
+
const hasCrossorigin = /crossorigin/.test(scriptTag);
|
|
125
|
+
|
|
126
|
+
if (!hasIntegrity) {
|
|
127
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
128
|
+
findings.push({
|
|
129
|
+
ruleId: 'WEB003',
|
|
130
|
+
ruleName: 'External Script Loading',
|
|
131
|
+
severity: 'medium',
|
|
132
|
+
category: 'webview',
|
|
133
|
+
message: 'External script loaded without Subresource Integrity (SRI)',
|
|
134
|
+
filePath,
|
|
135
|
+
line: lineNum,
|
|
136
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
137
|
+
remediation: 'Add integrity and crossorigin attributes to external scripts.',
|
|
138
|
+
references: ['https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity']
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return findings;
|
|
144
|
+
},
|
|
145
|
+
remediation: 'Add SRI (integrity) attribute to external script tags.'
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'WEB004',
|
|
149
|
+
name: 'Content Security Policy Missing',
|
|
150
|
+
description: 'Detects missing or weak Content Security Policy',
|
|
151
|
+
severity: 'medium',
|
|
152
|
+
category: 'webview',
|
|
153
|
+
filePatterns: ['**/index.html', '**/capacitor.config.*'],
|
|
154
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
155
|
+
const findings: Finding[] = [];
|
|
156
|
+
|
|
157
|
+
if (filePath.includes('index.html')) {
|
|
158
|
+
const hasCSP = /<meta[^>]*http-equiv\s*=\s*['"]Content-Security-Policy['"][^>]*>/i.test(content);
|
|
159
|
+
const hasUnsafeInline = /unsafe-inline|unsafe-eval/i.test(content);
|
|
160
|
+
|
|
161
|
+
if (!hasCSP) {
|
|
162
|
+
findings.push({
|
|
163
|
+
ruleId: 'WEB004',
|
|
164
|
+
ruleName: 'Content Security Policy Missing',
|
|
165
|
+
severity: 'medium',
|
|
166
|
+
category: 'webview',
|
|
167
|
+
message: 'No Content Security Policy meta tag found',
|
|
168
|
+
filePath,
|
|
169
|
+
line: 1,
|
|
170
|
+
remediation: 'Add a Content Security Policy meta tag to restrict resource loading.',
|
|
171
|
+
references: ['https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP']
|
|
172
|
+
});
|
|
173
|
+
} else if (hasUnsafeInline) {
|
|
174
|
+
findings.push({
|
|
175
|
+
ruleId: 'WEB004',
|
|
176
|
+
ruleName: 'Content Security Policy Missing',
|
|
177
|
+
severity: 'medium',
|
|
178
|
+
category: 'webview',
|
|
179
|
+
message: 'CSP contains unsafe-inline or unsafe-eval which weakens protection',
|
|
180
|
+
filePath,
|
|
181
|
+
line: 1,
|
|
182
|
+
remediation: 'Remove unsafe-inline and unsafe-eval. Use nonces or hashes for inline scripts.',
|
|
183
|
+
references: ['https://capacitor-sec.dev/docs/rules/csp']
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return findings;
|
|
189
|
+
},
|
|
190
|
+
remediation: 'Implement a strict Content Security Policy.'
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'WEB005',
|
|
194
|
+
name: 'Target _blank Without noopener',
|
|
195
|
+
description: 'Detects links with target="_blank" missing rel="noopener"',
|
|
196
|
+
severity: 'low',
|
|
197
|
+
category: 'webview',
|
|
198
|
+
filePatterns: ['**/*.html', '**/*.tsx', '**/*.jsx', '**/*.vue'],
|
|
199
|
+
check: (content: string, filePath: string): Finding[] => {
|
|
200
|
+
const findings: Finding[] = [];
|
|
201
|
+
const lines = content.split('\n');
|
|
202
|
+
|
|
203
|
+
// Check for target="_blank" without noopener
|
|
204
|
+
const linkPattern = /<a[^>]*target\s*=\s*['"]_blank['"][^>]*>/gi;
|
|
205
|
+
|
|
206
|
+
let match;
|
|
207
|
+
while ((match = linkPattern.exec(content)) !== null) {
|
|
208
|
+
const linkTag = match[0];
|
|
209
|
+
const hasNoopener = /rel\s*=\s*['"][^'"]*noopener[^'"]*['"]/.test(linkTag);
|
|
210
|
+
|
|
211
|
+
if (!hasNoopener) {
|
|
212
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
213
|
+
findings.push({
|
|
214
|
+
ruleId: 'WEB005',
|
|
215
|
+
ruleName: 'Target _blank Without noopener',
|
|
216
|
+
severity: 'low',
|
|
217
|
+
category: 'webview',
|
|
218
|
+
message: 'Link with target="_blank" missing rel="noopener" (Tabnabbing vulnerability)',
|
|
219
|
+
filePath,
|
|
220
|
+
line: lineNum,
|
|
221
|
+
codeSnippet: lines[lineNum - 1]?.trim(),
|
|
222
|
+
remediation: 'Add rel="noopener noreferrer" to links with target="_blank".',
|
|
223
|
+
references: ['https://owasp.org/www-community/attacks/Reverse_Tabnabbing']
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return findings;
|
|
229
|
+
},
|
|
230
|
+
remediation: 'Add rel="noopener noreferrer" to external links.'
|
|
231
|
+
}
|
|
232
|
+
];
|