redgun-security 1.1.0 → 1.3.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/README.md +0 -1
- package/bin/redgun.js +1 -1
- package/package.json +1 -1
- package/scan.js +19 -0
- package/src/core/reporter/console.js +1 -1
- package/src/core/reporter/html.js +1 -1
- package/src/local/ato.js +72 -0
- package/src/local/cicd.js +69 -0
- package/src/local/cloud.js +73 -0
- package/src/local/csrf.js +75 -0
- package/src/local/csti.js +71 -0
- package/src/local/index.js +36 -8
- package/src/local/jwt-advanced.js +70 -0
- package/src/local/ldap.js +67 -0
- package/src/local/mobile.js +71 -0
- package/src/local/padding-oracle.js +66 -0
- package/src/local/saml.js +72 -0
- package/src/local/service-worker.js +64 -0
- package/src/local/timing.js +66 -0
- package/src/local/web3.js +73 -0
- package/src/local/xpath-ssi.js +67 -0
- package/src/remote/advanced.js +238 -0
- package/src/remote/complete.js +240 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import { addFinding } from '../core/findings.js';
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.php', '.java', '.go', '.cs'];
|
|
6
|
+
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__', 'vendor'];
|
|
7
|
+
|
|
8
|
+
export async function auditLdap(projectPath, spinner) {
|
|
9
|
+
spinner.text = 'Scanning for LDAP injection...';
|
|
10
|
+
const files = getFiles(projectPath);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(file, 'utf-8');
|
|
15
|
+
const relativePath = file.replace(projectPath, '.');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
const patterns = [
|
|
19
|
+
{ pattern: /ldap\.(?:search|query|compare|modify)\s*\(\s*[^,]*,\s*(?:req|request|params|query|body|user|input|data)/gi, name: 'LDAP query with user input', severity: 'CRITICAL' },
|
|
20
|
+
{ pattern: /LDAP.*(?:filter|query)\s*[:=]\s*(?:req|request|params|query|body|user)\s*\+/gi, name: 'LDAP filter concatenation with user input', severity: 'CRITICAL' },
|
|
21
|
+
{ pattern: /ldap\.search\s*\(\s*[^,]*,\s*['"`][^'"`]*\$\{/gi, name: 'LDAP search with template literal user input', severity: 'CRITICAL' },
|
|
22
|
+
{ pattern: /ldap\.search\s*\(\s*[^,]*,\s*['"`][^'"`]*\+/gi, name: 'LDAP search with concatenated input', severity: 'CRITICAL' },
|
|
23
|
+
{ pattern: /(?:authenticate|ldap_auth|ldapauth|active.?directory)\s*\(\s*(?:req|request|params|query|body)/gi, name: 'LDAP auth with user-controlled filter', severity: 'CRITICAL' },
|
|
24
|
+
{ pattern: /ldap\.escape\s*\(\s*\)|ldap\.escapeFilter/gi, name: 'LDAP escaping used (good practice)', severity: 'INFO' },
|
|
25
|
+
{ pattern: /(?:ldap|AD|active.?directory).*(?:filter|query|search).*['"`]\s*\+/gi, name: 'LDAP filter concatenation detected', severity: 'HIGH' },
|
|
26
|
+
{ pattern: /(?:uid|cn|mail|samaccountname)\s*=\s*(?:req|request|params|query|body)/gi, name: 'LDAP attribute from user input', severity: 'HIGH' },
|
|
27
|
+
{ pattern: /ActiveDirectory\s*\(\s*\{/gi, name: 'Active Directory configuration', severity: 'INFO' },
|
|
28
|
+
{ pattern: /ldapjs|passport-ldap|ldapts|node-ldap/gi, name: 'LDAP library usage', severity: 'INFO' },
|
|
29
|
+
{ pattern: /(?:ldap|AD|active.?directory).*(?:URI|URL|host|server)\s*[:=]\s*['"][^'"]*['"]/gi, name: 'LDAP server config hardcoded', severity: 'LOW' },
|
|
30
|
+
{ pattern: /(?:ssl|tls|starttls|ldaps).*(?:false|disabled?|no|none)/gi, name: 'LDAP without TLS/SSL', severity: 'HIGH' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const { pattern, name, severity } of patterns) {
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
36
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
37
|
+
const line = lines[lineNum - 1]?.trim() || '';
|
|
38
|
+
if (line.startsWith('//') || line.startsWith('#') || line.startsWith('*')) continue;
|
|
39
|
+
|
|
40
|
+
addFinding(
|
|
41
|
+
severity,
|
|
42
|
+
'LDAP Injection',
|
|
43
|
+
name,
|
|
44
|
+
`File: ${relativePath}:${lineNum}\nCode: ${line.substring(0, 120)}`,
|
|
45
|
+
'Never concatenate user input into LDAP filters. Use parameterized LDAP queries or a safe LDAP query builder. Escape special characters (*, (, ), \\, /, &, |, !, =, <, >, ~, #) with ldap.escapeFilter(). Use LDAPS (LDAP over TLS) instead of plain LDAP.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getFiles(dir, files = []) {
|
|
54
|
+
try {
|
|
55
|
+
const entries = readdirSync(dir);
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (IGNORE_DIRS.includes(entry) || entry.startsWith('.')) continue;
|
|
58
|
+
const fullPath = join(dir, entry);
|
|
59
|
+
try {
|
|
60
|
+
const stat = statSync(fullPath);
|
|
61
|
+
if (stat.isDirectory()) getFiles(fullPath, files);
|
|
62
|
+
else if (SCAN_EXTENSIONS.includes(extname(entry).toLowerCase()) && stat.size < 512 * 1024) files.push(fullPath);
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import { addFinding } from '../core/findings.js';
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.xml', '.kt', '.swift', '.dart', '.json', '.plist'];
|
|
6
|
+
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__', 'vendor', 'android', 'ios'];
|
|
7
|
+
|
|
8
|
+
export async function auditMobile(projectPath, spinner) {
|
|
9
|
+
spinner.text = 'Scanning for mobile security issues...';
|
|
10
|
+
const files = getFiles(projectPath);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(file, 'utf-8');
|
|
15
|
+
const relativePath = file.replace(projectPath, '.');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
const patterns = [
|
|
19
|
+
{ pattern: /(?:firebase|supabase|API|SECRET|TOKEN|KEY).*\s*=\s*['"][A-Za-z0-9_\-\+=\/]{20,}['"]/gi, name: 'API key/secret exposed in mobile code', severity: 'CRITICAL' },
|
|
20
|
+
{ pattern: /android:allowBackup\s*=\s*"true"/gi, name: 'Android allowBackup=true (data extraction)', severity: 'MEDIUM' },
|
|
21
|
+
{ pattern: /android:debuggable\s*=\s*"true"/gi, name: 'Android debuggable=true in release', severity: 'HIGH' },
|
|
22
|
+
{ pattern: /android:usesCleartextTraffic\s*=\s*"true"/gi, name: 'Android cleartext (HTTP) traffic allowed', severity: 'HIGH' },
|
|
23
|
+
{ pattern: /NSAppTransportSecurity.*NSAllowsArbitraryLoads/gi, name: 'iOS ATS disabled (HTTP allowed)', severity: 'HIGH' },
|
|
24
|
+
{ pattern: /android:networkSecurityConfig/gi, name: 'Android custom network security config', severity: 'MEDIUM' },
|
|
25
|
+
{ pattern: /(?:NSAllowsLocalNetworking|NSTemporaryExceptionAllowsInsecureHTTPLoads)/gi, name: 'iOS ATS exceptions enabled', severity: 'MEDIUM' },
|
|
26
|
+
{ pattern: /android:exported\s*=\s*"true"/gi, name: 'Android component exported (IPC risk)', severity: 'MEDIUM' },
|
|
27
|
+
{ pattern: /android:protectionLevel\s*=\s*"normal"/gi, name: 'Android permission protectionLevel normal', severity: 'LOW' },
|
|
28
|
+
{ pattern: /deeplink|intent-filter.*data.*android:scheme/gi, name: 'Deep link / intent filter configured', severity: 'MEDIUM' },
|
|
29
|
+
{ pattern: /(?:React\.Native|flutter|ionic|cordova|capacitor)/gi, name: 'Cross-platform framework usage', severity: 'INFO' },
|
|
30
|
+
{ pattern: /(?:AsyncStorage|SharedPreferences|NSUserDefaults|Keychain|Keystore)/gi, name: 'Local storage usage (check encryption)', severity: 'INFO' },
|
|
31
|
+
{ pattern: /ssl.*pinning|certificate.*pinning|SSLPinningPlugin|TrustKit|certificatePinner/gi, name: 'Certificate pinning implemented', severity: 'INFO' },
|
|
32
|
+
{ pattern: /firebase\.io\.com|google-services\.json|GoogleService-Info\.plist/gi, name: 'Firebase config file referenced', severity: 'MEDIUM' },
|
|
33
|
+
{ pattern: /(?:root|jailbreak).*detect|isRooted|isJailbroken|rootbeer|safety.?net/gi, name: 'Root/jailbreak detection', severity: 'INFO' },
|
|
34
|
+
{ pattern: /(?:WebView|WKWebView).*(?:setJavaScriptEnabled|javaScriptEnabled)/gi, name: 'WebView JS enabled (XSS surface)', severity: 'MEDIUM' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
for (const { pattern, name, severity } of patterns) {
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
40
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
41
|
+
const line = lines[lineNum - 1]?.trim() || '';
|
|
42
|
+
if (line.startsWith('//') || line.startsWith('#') || line.startsWith('*') || line.startsWith('<!--')) continue;
|
|
43
|
+
|
|
44
|
+
addFinding(
|
|
45
|
+
severity,
|
|
46
|
+
'Mobile Security',
|
|
47
|
+
name,
|
|
48
|
+
`File: ${relativePath}:${lineNum}\nCode: ${line.substring(0, 120)}`,
|
|
49
|
+
'Never hardcode API keys/secrets in mobile apps. Use server-side proxying. Enable certificate pinning. Set debuggable=false for release builds. Disable allowBackup unless encrypted. Use Network Security Config to restrict cleartext traffic. Enable minify/obfuscation for production.'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getFiles(dir, files = []) {
|
|
58
|
+
try {
|
|
59
|
+
const entries = readdirSync(dir);
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (IGNORE_DIRS.includes(entry) || entry.startsWith('.')) continue;
|
|
62
|
+
const fullPath = join(dir, entry);
|
|
63
|
+
try {
|
|
64
|
+
const stat = statSync(fullPath);
|
|
65
|
+
if (stat.isDirectory()) getFiles(fullPath, files);
|
|
66
|
+
else if (SCAN_EXTENSIONS.includes(extname(entry).toLowerCase()) && stat.size < 512 * 1024) files.push(fullPath);
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
return files;
|
|
71
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import { addFinding } from '../core/findings.js';
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.php', '.go', '.java', '.c', '.cpp', '.cs'];
|
|
6
|
+
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__', 'vendor'];
|
|
7
|
+
|
|
8
|
+
export async function auditPaddingOracle(projectPath, spinner) {
|
|
9
|
+
spinner.text = 'Scanning for padding/comression oracle vulnerabilities...';
|
|
10
|
+
const files = getFiles(projectPath);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(file, 'utf-8');
|
|
15
|
+
const relativePath = file.replace(projectPath, '.');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
const patterns = [
|
|
19
|
+
{ pattern: /(?:decrypt|decipher|unpad)\s*\([^)]*\)\s*(?:catch|if.*error)/gi, name: 'Decryption with error handling (padding oracle)', severity: 'HIGH' },
|
|
20
|
+
{ pattern: /(?:padding|decrypt)\s*(?:bad|invalid|wrong|error|fail)/gi, name: 'Decryption error message (padding oracle indicator)', severity: 'HIGH' },
|
|
21
|
+
{ pattern: /CBC|cipher\.final|cipher\.update/gi, name: 'CBC mode encryption (padding oracle vector)', severity: 'MEDIUM' },
|
|
22
|
+
{ pattern: /createDecipheriv\s*\(\s*['"](?:aes|des)-.*-(?:cbc|ecb)/gi, name: 'CBC/ECB mode decrypt (potential padding oracle)', severity: 'MEDIUM' },
|
|
23
|
+
{ pattern: /(?:error|exception|err)\s*\(\s*['"](?:bad|invalid|wrong) (?:padding|decrypt|cipher)/gi, name: 'Padding error in response', severity: 'HIGH' },
|
|
24
|
+
{ pattern: /OAEP|RSASSA-PSS|RSAES-OAEP/gi, name: 'RSA OAEP padding (good)', severity: 'INFO' },
|
|
25
|
+
{ pattern: /PKCS|pkcs/i, name: 'PKCS padding reference', severity: 'INFO' },
|
|
26
|
+
{ pattern: /gzip|deflate|compress|zlib|Content-Encoding/gi, name: 'Compression usage (CRIME/BREACH vector)', severity: 'LOW' },
|
|
27
|
+
{ pattern: /(?:token|session|cookie|jwt).*(?:compress|gzip|deflate)/gi, name: 'Compression of secrets/tokens (CRIME/BREACH)', severity: 'MEDIUM' },
|
|
28
|
+
{ pattern: /(?:https|tls|ssl).*(?:compress|gzip|deflate)/gi, name: 'TLS compression reference (CRIME)', severity: 'MEDIUM' },
|
|
29
|
+
{ pattern: /\bcrypto\.createDecipheriv\b/gi, name: 'Node.js createDecipheriv (check error handling)', severity: 'MEDIUM' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
for (const { pattern, name, severity } of patterns) {
|
|
33
|
+
let match;
|
|
34
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
35
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
36
|
+
const line = lines[lineNum - 1]?.trim() || '';
|
|
37
|
+
if (line.startsWith('//') || line.startsWith('#') || line.startsWith('*')) continue;
|
|
38
|
+
|
|
39
|
+
addFinding(
|
|
40
|
+
severity,
|
|
41
|
+
'Padding / Compression Oracle',
|
|
42
|
+
name,
|
|
43
|
+
`File: ${relativePath}:${lineNum}\nCode: ${line.substring(0, 120)}`,
|
|
44
|
+
'Use authenticated encryption (AES-GCM, ChaCha20-Poly1305) instead of CBC. Never leak decryption errors to clients. Add random MAC before encrypting. Disable TLS compression. Avoid compressing response bodies that contain secrets or CSRF tokens.'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getFiles(dir, files = []) {
|
|
53
|
+
try {
|
|
54
|
+
const entries = readdirSync(dir);
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (IGNORE_DIRS.includes(entry) || entry.startsWith('.')) continue;
|
|
57
|
+
const fullPath = join(dir, entry);
|
|
58
|
+
try {
|
|
59
|
+
const stat = statSync(fullPath);
|
|
60
|
+
if (stat.isDirectory()) getFiles(fullPath, files);
|
|
61
|
+
else if (SCAN_EXTENSIONS.includes(extname(entry).toLowerCase()) && stat.size < 512 * 1024) files.push(fullPath);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import { addFinding } from '../core/findings.js';
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.php', '.java', '.go', '.xml', '.env', '.conf', '.yml', '.yaml'];
|
|
6
|
+
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__', 'vendor'];
|
|
7
|
+
|
|
8
|
+
export async function auditSaml(projectPath, spinner) {
|
|
9
|
+
spinner.text = 'Scanning for SAML/SSO vulnerabilities...';
|
|
10
|
+
const files = getFiles(projectPath);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(file, 'utf-8');
|
|
15
|
+
const relativePath = file.replace(projectPath, '.');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
const patterns = [
|
|
19
|
+
{ pattern: /signature\s*.*(?:skip|disabled?|false|none)/gi, name: 'SAML signature validation disabled/skipped', severity: 'CRITICAL' },
|
|
20
|
+
{ pattern: /(?:validate|verify|check).*signature\s*.*(?:false|null|undefined|skip)/gi, name: 'Signature verification disabled', severity: 'CRITICAL' },
|
|
21
|
+
{ pattern: /wantAssertionsSigned\s*[:=]\s*false/gi, name: 'Assertions signing not required', severity: 'HIGH' },
|
|
22
|
+
{ pattern: /wantAuthnRequestsSigned\s*[:=]\s*false/gi, name: 'AuthnRequest signing not required', severity: 'HIGH' },
|
|
23
|
+
{ pattern: /(?:IDP|idp|identityProvider).*(?:metadata|config|cert|certificate).*url\s*=.*(?:http:\/\/|request)/gi, name: 'IdP metadata from dynamic/untrusted URL', severity: 'HIGH' },
|
|
24
|
+
{ pattern: /saml2\.validatePostResponse\s*\(/gi, name: 'SAML response validation (check for XSW)', severity: 'MEDIUM' },
|
|
25
|
+
{ pattern: /saml2\.validateRedirect\s*\(/gi, name: 'SAML redirect binding', severity: 'INFO' },
|
|
26
|
+
{ pattern: /(?:audience|AudienceRestriction)\s*.*(?:skip|disabled?|false)/gi, name: 'Audience restriction disabled', severity: 'HIGH' },
|
|
27
|
+
{ pattern: /(?:entityID|issuer)\s*.*(?:check|validate)\s*.*(?:false|skip)/gi, name: 'Entity ID validation skipped', severity: 'MEDIUM' },
|
|
28
|
+
{ pattern: /(?:notBefore|NotOnOrAfter|notOnOrAfter|clockSkew|skew)\s*[:=]\s*\d{4,}/gi, name: 'Large SAML clock skew (replay risk)', severity: 'LOW' },
|
|
29
|
+
{ pattern: /NameID\s*.*(?:req|request|input|body|query|params|user)/gi, name: 'NameID from user input', severity: 'CRITICAL' },
|
|
30
|
+
{ pattern: /(?:attributes?|AttributeStatement)\s*.*(?:req|request|input|body)/gi, name: 'SAML attributes from user input', severity: 'CRITICAL' },
|
|
31
|
+
{ pattern: /InResponseTo\s*.*(?:skip|disabled?|false|null)/gi, name: 'InResponseTo validation skipped', severity: 'HIGH' },
|
|
32
|
+
{ pattern: /(?:SP|serviceProvider|SAML).*(?:cert|certificate)\s*[:=]\s*['"]/gi, name: 'SAML certificate hardcoded in code', severity: 'LOW' },
|
|
33
|
+
{ pattern: /XMLSignature\s*\(\s*\)/gi, name: 'XML signature object (check for XSW bypass)', severity: 'MEDIUM' },
|
|
34
|
+
{ pattern: /(?:find|query|select)(?:Selector)?\s*\(\s*['"]\/*\/(?:\w+:)?Assertion['"]\s*\)/gi, name: 'XPath query for Assertion (XSW vulnerability)', severity: 'HIGH' },
|
|
35
|
+
{ pattern: /(?:find|query|select)(?:Selector)?\s*\(\s*['"](?:\w+:)?(?:AttributeStatement|NameID|Subject)['"]\s*\)/gi, name: 'XPath query for SAML elements (check for XSW)', severity: 'MEDIUM' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
for (const { pattern, name, severity } of patterns) {
|
|
39
|
+
let match;
|
|
40
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
41
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
42
|
+
const line = lines[lineNum - 1]?.trim() || '';
|
|
43
|
+
if (line.startsWith('//') || line.startsWith('#') || line.startsWith('*')) continue;
|
|
44
|
+
|
|
45
|
+
addFinding(
|
|
46
|
+
severity,
|
|
47
|
+
'SAML/SSO Attacks',
|
|
48
|
+
name,
|
|
49
|
+
`File: ${relativePath}:${lineNum}\nCode: ${line.substring(0, 120)}`,
|
|
50
|
+
'Validate SAML signatures with trusted Identity Provider certificates. Never accept unsigned assertions. Use XML Signature Wrapping (XSW) defenses: validate the exact XPath of signed elements. Always enforce audience restriction and InResponseTo. Do not accept NameID or attributes from user input.'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getFiles(dir, files = []) {
|
|
59
|
+
try {
|
|
60
|
+
const entries = readdirSync(dir);
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (IGNORE_DIRS.includes(entry) || entry.startsWith('.')) continue;
|
|
63
|
+
const fullPath = join(dir, entry);
|
|
64
|
+
try {
|
|
65
|
+
const stat = statSync(fullPath);
|
|
66
|
+
if (stat.isDirectory()) getFiles(fullPath, files);
|
|
67
|
+
else if (SCAN_EXTENSIONS.includes(extname(entry).toLowerCase()) && stat.size < 1024 * 1024) files.push(fullPath);
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
return files;
|
|
72
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import { addFinding } from '../core/findings.js';
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.html', '.htm'];
|
|
6
|
+
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__', 'vendor'];
|
|
7
|
+
|
|
8
|
+
export async function auditServiceWorker(projectPath, spinner) {
|
|
9
|
+
spinner.text = 'Scanning for Service Worker abuse vectors...';
|
|
10
|
+
const files = getFiles(projectPath);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(file, 'utf-8');
|
|
15
|
+
const relativePath = file.replace(projectPath, '.');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
const patterns = [
|
|
19
|
+
{ pattern: /navigator\.serviceWorker\.register\s*\(/gi, name: 'Service Worker registration', severity: 'INFO' },
|
|
20
|
+
{ pattern: /importScripts\s*\(/gi, name: 'Service Worker importScripts (XSS via sw)', severity: 'HIGH' },
|
|
21
|
+
{ pattern: /self\.addEventListener\s*\(\s*['"]fetch['"]/gi, name: 'Service Worker fetch listener', severity: 'INFO' },
|
|
22
|
+
{ pattern: /self\.addEventListener\s*\(\s*['"]message['"]/gi, name: 'Service Worker message listener (postMessage attack)', severity: 'MEDIUM' },
|
|
23
|
+
{ pattern: /respondWith\s*\(\s*fetch\s*\(\s*event\.request\s*\)/gi, name: 'SW passthrough fetch (check URL rewriting)', severity: 'MEDIUM' },
|
|
24
|
+
{ pattern: /event\.respondWith\s*\(\s*new\s+Response/gi, name: 'SW custom response (check for content injection)', severity: 'MEDIUM' },
|
|
25
|
+
{ pattern: /CacheStorage|self\.caches\s*\./gi, name: 'Cache API usage (cache poisoning via SW)', severity: 'LOW' },
|
|
26
|
+
{ pattern: /Clients\.claim\s*\(\s*\)|self\.clients\.claim/gi, name: 'Service Worker immediate claim (malicious takeover)', severity: 'HIGH' },
|
|
27
|
+
{ pattern: /self\.skipWaiting/gi, name: 'SW skipWaiting (immediate activation)', severity: 'MEDIUM' },
|
|
28
|
+
{ pattern: /navigator\.serviceWorker\.getRegistration|navigator\.serviceWorker\.controller/gi, name: 'SW control check', severity: 'INFO' },
|
|
29
|
+
{ pattern: /self\.registration\.unregister/gi, name: 'SW unregistration', severity: 'LOW' },
|
|
30
|
+
{ pattern: /PushManager|pushManager\.subscribe|showNotification/gi, name: 'Push notifications (notification spam vector)', severity: 'LOW' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const { pattern, name, severity } of patterns) {
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
36
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
37
|
+
addFinding(
|
|
38
|
+
severity,
|
|
39
|
+
'Service Worker / WebRTC',
|
|
40
|
+
name,
|
|
41
|
+
`File: ${relativePath}:${lineNum}\nCode: ${lines[lineNum - 1]?.trim().substring(0, 120)}`,
|
|
42
|
+
'Restrict Service Worker scope (/). Use importScripts from same-origin only. Validate fetch events to prevent response tampering. Monitor dangerously overwritable headers. Limit Cache API usage to prevent storage exhaustion.'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getFiles(dir, files = []) {
|
|
51
|
+
try {
|
|
52
|
+
const entries = readdirSync(dir);
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (IGNORE_DIRS.includes(entry) || entry.startsWith('.')) continue;
|
|
55
|
+
const fullPath = join(dir, entry);
|
|
56
|
+
try {
|
|
57
|
+
const stat = statSync(fullPath);
|
|
58
|
+
if (stat.isDirectory()) getFiles(fullPath, files);
|
|
59
|
+
else if (SCAN_EXTENSIONS.includes(extname(entry).toLowerCase()) && stat.size < 512 * 1024) files.push(fullPath);
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import { addFinding } from '../core/findings.js';
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.php', '.go', '.java'];
|
|
6
|
+
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__', 'vendor'];
|
|
7
|
+
|
|
8
|
+
export async function auditTiming(projectPath, spinner) {
|
|
9
|
+
spinner.text = 'Scanning for timing side-channel vulnerabilities...';
|
|
10
|
+
const files = getFiles(projectPath);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(file, 'utf-8');
|
|
15
|
+
const relativePath = file.replace(projectPath, '.');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
const patterns = [
|
|
19
|
+
{ pattern: /(?:password|secret|token|key|hash|pin).*\s*={2,3}\s*(?:req|request|params|body|input)/gi, name: 'String comparison with user input (timing leak)', severity: 'HIGH' },
|
|
20
|
+
{ pattern: /(?:password|secret|token|key|hash|pin)\s*===\s*/gi, name: 'Strict comparison used (good for timing)', severity: 'INFO' },
|
|
21
|
+
{ pattern: /crypto\.timingSafeEqual|timingsafe\b/gi, name: 'Timing-safe comparison used (good)', severity: 'INFO' },
|
|
22
|
+
{ pattern: /\b(?:sleep|delay|wait|setTimeout|setInterval)\s*\(\s*(?:req|request|params|query|body)/gi, name: 'User-controlled delay/sleep (potential timing oracle)', severity: 'MEDIUM' },
|
|
23
|
+
{ pattern: /(?:if|when)\s*\([^)]*(?:req|request|params|query|body)[^)]*\)\s*(?:\{|\n)\s*(?:sleep|delay|setTimeout)/gi, name: 'Conditional sleep based on user input', severity: 'HIGH' },
|
|
24
|
+
{ pattern: /(?:login|auth|signin).*select.*password.*(?:req|request|params)/gi, name: 'Password DB lookup (check for timing leak on user-not-found)', severity: 'MEDIUM' },
|
|
25
|
+
{ pattern: /bcrypt\.compare|bcrypt\.compareSync|argon2\.verify/gi, name: 'bcrypt/argon2 verification (timing-safe)', severity: 'INFO' },
|
|
26
|
+
{ pattern: /(?:hash|digest|hmac)\s*\(\s*['"]?(?:md5|sha1|sha256|sha512)['"]?\s*,/gi, name: 'Hash function usage (check HMAC timing)', severity: 'LOW' },
|
|
27
|
+
{ pattern: /(?:for|while)\s*\([^)]*(?:req|request|params|query|body)[^)]*\)/gi, name: 'Loop iteration from user input (DoS + timing oracle)', severity: 'MEDIUM' },
|
|
28
|
+
{ pattern: /(?:username|email|user).*exists|(?:findOne|findUser)\s*\(\s*\{.*username/gi, name: 'User existence check (potential oracle)', severity: 'LOW' },
|
|
29
|
+
{ pattern: /PasswordResetListener|SentMessage|mail.*send.*token/gi, name: 'Email sending after reset request (timing oracle on user existence)', severity: 'LOW' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
for (const { pattern, name, severity } of patterns) {
|
|
33
|
+
let match;
|
|
34
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
35
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
36
|
+
const line = lines[lineNum - 1]?.trim() || '';
|
|
37
|
+
if (line.startsWith('//') || line.startsWith('#') || line.startsWith('*')) continue;
|
|
38
|
+
|
|
39
|
+
addFinding(
|
|
40
|
+
severity,
|
|
41
|
+
'Timing Side-Channels',
|
|
42
|
+
name,
|
|
43
|
+
`File: ${relativePath}:${lineNum}\nCode: ${line.substring(0, 120)}`,
|
|
44
|
+
'Use constant-time comparison (timingSafeEqual) for secrets. Ensure login returns identical responses regardless of user existence. Add random jitter to email sending. Never use sleep/delay from user input.'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getFiles(dir, files = []) {
|
|
53
|
+
try {
|
|
54
|
+
const entries = readdirSync(dir);
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (IGNORE_DIRS.includes(entry) || entry.startsWith('.')) continue;
|
|
57
|
+
const fullPath = join(dir, entry);
|
|
58
|
+
try {
|
|
59
|
+
const stat = statSync(fullPath);
|
|
60
|
+
if (stat.isDirectory()) getFiles(fullPath, files);
|
|
61
|
+
else if (SCAN_EXTENSIONS.includes(extname(entry).toLowerCase()) && stat.size < 512 * 1024) files.push(fullPath);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import { addFinding } from '../core/findings.js';
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTENSIONS = ['.sol', '.rs', '.js', '.ts', '.jsx', '.tsx', '.py', '.vy', '.move'];
|
|
6
|
+
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__', 'vendor', 'out'];
|
|
7
|
+
|
|
8
|
+
export async function auditWeb3(projectPath, spinner) {
|
|
9
|
+
spinner.text = 'Scanning for Web3/smart contract vulnerabilities...';
|
|
10
|
+
const files = getFiles(projectPath);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(file, 'utf-8');
|
|
15
|
+
const relativePath = file.replace(projectPath, '.');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
const patterns = [
|
|
19
|
+
{ pattern: /reentrancy|re-?entrant|nonReentrant/gi, name: 'Reentrancy guard pattern', severity: 'INFO' },
|
|
20
|
+
{ pattern: /\.call\s*\(\s*\{\s*value:|\.transfer\s*\(|\.send\s*\(/gi, name: 'ETH transfer method (check reentrancy)', severity: 'HIGH' },
|
|
21
|
+
{ pattern: /(?:transfer|send)\s*\(\s*(?:msg\.value|address\(this\)\.balance)/gi, name: 'Full balance transfer (reentrancy risk)', severity: 'CRITICAL' },
|
|
22
|
+
{ pattern: /(?:balances?|mapping).*\s*\[.*\s*\]\s*[-+]=?\s*/gi, name: 'Balance update (check CEI pattern)', severity: 'MEDIUM' },
|
|
23
|
+
{ pattern: /delegatecall|delegatecall\s*\(/gi, name: 'delegatecall usage (storage collision risk)', severity: 'CRITICAL' },
|
|
24
|
+
{ pattern: /selfdestruct|suicide\s*\(/gi, name: 'selfdestruct usage', severity: 'HIGH' },
|
|
25
|
+
{ pattern: /tx\.origin\s*={2,3}|require\s*\(\s*tx\.origin/gi, name: 'tx.origin used for auth (phishing risk)', severity: 'CRITICAL' },
|
|
26
|
+
{ pattern: /block\.timestamp.*==|block\.timestamp.*<=|block\.timestamp.*>=/gi, name: 'block.timestamp in strict comparison', severity: 'MEDIUM' },
|
|
27
|
+
{ pattern: /blockhash\s*\(|block\.blockhash/gi, name: 'Blockhash usage (predictable in multi-block context)', severity: 'MEDIUM' },
|
|
28
|
+
{ pattern: /onlyOwner|Ownable|owner\s*=\s*msg\.sender/gi, name: 'Ownable pattern (single point of failure)', severity: 'MEDIUM' },
|
|
29
|
+
{ pattern: /mint\s*\(\s*.*address|_mint\s*\(/gi, name: 'Mint function (check access control)', severity: 'HIGH' },
|
|
30
|
+
{ pattern: /proxy|implementation|upgrade|initialize/gi, name: 'Upgradeable/proxy pattern', severity: 'INFO' },
|
|
31
|
+
{ pattern: /storage\s+|assembly\s*\{.*sload|sstore/gi, name: 'Inline assembly with storage access', severity: 'HIGH' },
|
|
32
|
+
{ pattern: /unchecked\s*\{|unchecked\s*\(/gi, name: 'unchecked block (integer overflow risk)', severity: 'MEDIUM' },
|
|
33
|
+
{ pattern: /uint\d+\s*\+\s*|uint\d+\s*-\s*|uint\d+\s*\*\s*/gi, name: 'Integer arithmetic (check overflow/underflow if Solidity < 0.8)', severity: 'MEDIUM' },
|
|
34
|
+
{ pattern: /msg\.value\s*>=\s*0|msg\.value\s*==\s*0/gi, name: 'msg.value comparison (check edge cases)', severity: 'LOW' },
|
|
35
|
+
{ pattern: /(?:constructor|init)\s*\([^)]*(?:_proxy|_impl|_implementation)/gi, name: 'Proxy initialization (check for double-init)', severity: 'HIGH' },
|
|
36
|
+
{ pattern: /(?:deadline|expiry|expires).*(?:require|if|assert)\s*\(/gi, name: 'Deadline check (frontrunning protection)', severity: 'MEDIUM' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
for (const { pattern, name, severity } of patterns) {
|
|
40
|
+
let match;
|
|
41
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
42
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
43
|
+
const line = lines[lineNum - 1]?.trim() || '';
|
|
44
|
+
if (line.startsWith('//') || line.startsWith('#') || line.startsWith('*')) continue;
|
|
45
|
+
|
|
46
|
+
addFinding(
|
|
47
|
+
severity,
|
|
48
|
+
'Web3 / Smart Contracts',
|
|
49
|
+
name,
|
|
50
|
+
`File: ${relativePath}:${lineNum}\nCode: ${line.substring(0, 120)}`,
|
|
51
|
+
'Use Checks-Effects-Interactions pattern. Use ReentrancyGuard from OpenZeppelin. Verify proxy initializer is only called once. Use msg.sender instead of tx.origin for auth. Use block.timestamp >= for time comparisons. Run slither/aderyn for deeper analysis.'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getFiles(dir, files = []) {
|
|
60
|
+
try {
|
|
61
|
+
const entries = readdirSync(dir);
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (IGNORE_DIRS.includes(entry) || entry.startsWith('.')) continue;
|
|
64
|
+
const fullPath = join(dir, entry);
|
|
65
|
+
try {
|
|
66
|
+
const stat = statSync(fullPath);
|
|
67
|
+
if (stat.isDirectory()) getFiles(fullPath, files);
|
|
68
|
+
else if (SCAN_EXTENSIONS.includes(extname(entry).toLowerCase()) && stat.size < 256 * 1024) files.push(fullPath);
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
return files;
|
|
73
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, extname } from 'path';
|
|
3
|
+
import { addFinding } from '../core/findings.js';
|
|
4
|
+
|
|
5
|
+
const SCAN_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.php', '.go', '.java'];
|
|
6
|
+
const IGNORE_DIRS = ['node_modules', '.git', 'dist', 'build', '__pycache__', 'vendor'];
|
|
7
|
+
|
|
8
|
+
export async function auditXpathSsi(projectPath, spinner) {
|
|
9
|
+
spinner.text = 'Scanning for XPath & SSI injection...';
|
|
10
|
+
const files = getFiles(projectPath);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(file, 'utf-8');
|
|
15
|
+
const relativePath = file.replace(projectPath, '.');
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
|
|
18
|
+
const patterns = [
|
|
19
|
+
{ pattern: /XPath\.evaluate\s*\(\s*(?:req|request|params|query|body|user)/gi, name: 'XPath evaluate with user input', severity: 'CRITICAL' },
|
|
20
|
+
{ pattern: /xpath\.select\s*\(\s*(?:req|request|params|query|body)/gi, name: 'XPath select with user input', severity: 'CRITICAL' },
|
|
21
|
+
{ pattern: /(?:find|query|select)\s*\(\s*['"`]\/\/(?:\w+::)?(\w+)\[.*\$\{/gi, name: 'XPath query with template literal', severity: 'CRITICAL' },
|
|
22
|
+
{ pattern: /(?:find|query|select)\s*\(\s*['"`]\/\/(?:\w+::)?(\w+)\[.*\+/gi, name: 'XPath query with concatenated input', severity: 'CRITICAL' },
|
|
23
|
+
{ pattern: /SimpleXMLElement.*xpath\s*\(\s*\$_(?:GET|POST|REQUEST)/gi, name: 'PHP SimpleXML xpath user input', severity: 'CRITICAL' },
|
|
24
|
+
{ pattern: /DOMXPath.*query\s*\(\s*\$_(?:GET|POST|REQUEST)/gi, name: 'PHP DOMXPath query user input', severity: 'CRITICAL' },
|
|
25
|
+
{ pattern: /xpath\.compile\s*\(\s*(?:req|request|input)/gi, name: 'XPath compile with user input', severity: 'HIGH' },
|
|
26
|
+
{ pattern: /<!--#\s*(?:include|exec|echo|fsize|flastmod|config)/gi, name: 'Server-Side Includes (SSI) in templates', severity: 'HIGH' },
|
|
27
|
+
{ pattern: /<!--#echo\s+var|<!--#include\s+(?:virtual|file)|<!--#exec/gi, name: 'SSI directives in source', severity: 'CRITICAL' },
|
|
28
|
+
{ pattern: /(?:shtml|stm|shtm)/gi, name: 'SSI file extension (.shtml)', severity: 'MEDIUM' },
|
|
29
|
+
{ pattern: /Options\s+\+Includes|AddHandler.*server-parsed/gi, name: 'Apache SSI enabled', severity: 'LOW' },
|
|
30
|
+
{ pattern: /(?:include|exec|echo)\s*\(\s*(?:req|request|params|input)/gi, name: 'Server include with user input', severity: 'CRITICAL' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
for (const { pattern, name, severity } of patterns) {
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
36
|
+
const lineNum = content.substring(0, match.index).split('\n').length;
|
|
37
|
+
const line = lines[lineNum - 1]?.trim() || '';
|
|
38
|
+
if (line.startsWith('//') || line.startsWith('#') || line.startsWith('*')) continue;
|
|
39
|
+
|
|
40
|
+
addFinding(
|
|
41
|
+
severity,
|
|
42
|
+
'XPath / SSI Injection',
|
|
43
|
+
name,
|
|
44
|
+
`File: ${relativePath}:${lineNum}\nCode: ${line.substring(0, 120)}`,
|
|
45
|
+
'Use parameterized XPath queries. Sanitize input against special chars (, ), [, ], :, *, /, @, !, =, >, <. Disable SSI unless needed (Options -Includes). Never pass user input to SSI directives.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getFiles(dir, files = []) {
|
|
54
|
+
try {
|
|
55
|
+
const entries = readdirSync(dir);
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (IGNORE_DIRS.includes(entry) || entry.startsWith('.')) continue;
|
|
58
|
+
const fullPath = join(dir, entry);
|
|
59
|
+
try {
|
|
60
|
+
const stat = statSync(fullPath);
|
|
61
|
+
if (stat.isDirectory()) getFiles(fullPath, files);
|
|
62
|
+
else if (SCAN_EXTENSIONS.includes(extname(entry).toLowerCase()) && stat.size < 512 * 1024) files.push(fullPath);
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
return files;
|
|
67
|
+
}
|