UNPKG

chrome-extension-test-framework

Version:

Universal testing framework for Chrome extensions - Fast static analysis without browser dependencies

405 lines (364 loc) 18 kB
/** * SecurityAnalyzer * Chrome拡張機能の高度なセキュリティ分析を行うクラス */ const fs = require('fs'); const path = require('path'); const ChromePatternRecognizer = require('./ChromePatternRecognizer'); const EnvironmentDetector = require('./EnvironmentDetector'); const DiagnosticHelper = require('./DiagnosticHelper'); const SeverityManager = require('./SeverityManager'); const ImprovedErrorMessages = require('./ImprovedErrorMessages'); const ExcludeManager = require('./ExcludeManager'); class SecurityAnalyzer { constructor(options = {}) { this.options = options; this.chromeRecognizer = new ChromePatternRecognizer(); this.environmentDetector = new EnvironmentDetector(); this.severityManager = new SeverityManager(options); this.errorMessages = new ImprovedErrorMessages(); this.excludeManager = new ExcludeManager(options); // 診断モード(環境変数またはconfigで有効化) this.diagnosticMode = process.env.CEXT_DIAGNOSTIC === 'true' || options.diagnostic === true; // セキュリティパターンの定義 this.patterns = { // APIキーのパターン apiKeys: [ { name: 'Generic API Key', pattern: /(?:const|let|var)\s+\w*(?:api[_-]?key|API[_-]?KEY)\w*\s*=\s*['"][^'"]+['"]/, severity: 'critical' }, { name: 'Hardcoded API Key', pattern: /['"]?api[_-]?key['"]?\s*[:=]\s*['"][a-zA-Z0-9\-_]{20,}['"]/, severity: 'critical' }, { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/, severity: 'critical' }, { name: 'Google API Key', pattern: /AIza[0-9A-Za-z\-_]{35}/, severity: 'critical' }, { name: 'Firebase API Key', pattern: /AIza[0-9A-Za-z\-_]{35}/, severity: 'critical' }, { name: 'Private Key', pattern: /-----BEGIN (RSA |EC )?PRIVATE KEY-----/, severity: 'critical' }, { name: 'OAuth Secret', pattern: /['"]?client[_-]?secret['"]?\s*[:=]\s*['"][a-zA-Z0-9\-_]{20,}['"]/, severity: 'critical' }, { name: 'Hardcoded Secret', pattern: /(?:const|let|var)\s+\w*(?:secret|SECRET)\w*\s*=\s*['"][^'"]+['"]/, severity: 'critical' }, { name: 'Hardcoded Token', pattern: /(?:const|let|var)\s+\w*(?:token|TOKEN)\w*\s*=\s*['"][^'"]+['"]/, severity: 'critical' }, { name: 'Password in Code', pattern: /(?:const|let|var)\s+\w*(?:password|PASSWORD)\w*\s*=\s*['"][^'"]+['"]/, severity: 'critical' }, { name: 'Password Assignment', pattern: /['"]?password['"]?\s*[:=]\s*['"][^'"]{6,}['"]/, severity: 'high' }, { name: 'Token Assignment', pattern: /['"]?token['"]?\s*[:=]\s*['"][a-zA-Z0-9\-_.]{20,}['"]/, severity: 'high' } ], // 安全でないストレージパターン insecureStorage: [ { name: 'localStorage with sensitive data', pattern: /localStorage\.(setItem|getItem)\s*\(\s*['"]?(password|token|key|secret|credential)/, severity: 'high' }, { name: 'sessionStorage with sensitive data', pattern: /sessionStorage\.(setItem|getItem)\s*\(\s*['"]?(password|token|key|secret|credential)/, severity: 'medium' }, { name: 'Cookie without secure flag', pattern: /document\.cookie\s*=(?!.*;\s*secure)/, severity: 'medium' }, { name: 'IndexedDB with sensitive data', pattern: /indexedDB.*?(password|token|key|secret|credential)/, severity: 'medium' }, { name: 'chrome.storage with password', pattern: /chrome\.storage\.(local|sync)\.set\s*\(\s*\{[^}]*password[^}]*\}/, severity: 'critical' }, { name: 'chrome.storage with token', pattern: /chrome\.storage\.(local|sync)\.set\s*\(\s*\{[^}]*token[^}]*\}/, severity: 'high' }, { name: 'chrome.storage with credit card', pattern: /chrome\.storage\.(local|sync)\.set\s*\(\s*\{[^}]*credit[Cc]ard[^}]*\}/, severity: 'critical' }, { name: 'chrome.storage with API key', pattern: /chrome\.storage\.(local|sync)\.set\s*\(\s*\{[^}]*apiKey[^}]*\}/, severity: 'critical' } ], // 危険な関数の使用 dangerousFunctions: [ { name: 'eval()', pattern: /\beval\s*\(/, severity: 'critical' }, { name: 'Function constructor', pattern: /new\s+Function\s*\(/, severity: 'critical' }, { name: 'setTimeout with string', pattern: /setTimeout\s*\(\s*['"]/, severity: 'high' }, { name: 'setInterval with string', pattern: /setInterval\s*\(\s*['"]/, severity: 'high' }, { name: 'document.write', pattern: /document\.(write|writeln)\s*\(/, severity: 'medium' } ], // XSS脆弱性パターン xssVulnerabilities: [ { name: 'innerHTML with user input', pattern: /\.innerHTML\s*=\s*[^'"]/, severity: 'high' }, { name: 'outerHTML with user input', pattern: /\.outerHTML\s*=\s*[^'"]/, severity: 'high' }, { name: 'insertAdjacentHTML', pattern: /\.insertAdjacentHTML\s*\(/, severity: 'medium' }, { name: 'jQuery html() with variables', pattern: /\$\([^)]+\)\.html\s*\(\s*[^'"]/, severity: 'medium' } ], // 安全でない通信 insecureCommunication: [ { name: 'HTTP URL', pattern: /http:\/\/(?!localhost|127\.0\.0\.1)/, severity: 'medium' }, { name: 'Unvalidated postMessage', pattern: /addEventListener\s*\(\s*['"]message['"].*?\{(?!.*origin\s*[!=]==)/, severity: 'high' }, { name: 'wildcard postMessage target', pattern: /postMessage\s*\([^,]+,\s*['"]\*['"]/, severity: 'high' } ] }; this.results = { issues: [], stats: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, scannedFiles: 0 }; } /** * 拡張機能全体を分析 */ async analyze(extensionPath, excludeManager = null) { // ExcludeManagerを設定(引数で渡されるか、コンストラクタで設定されたもの) if (excludeManager) { this.excludeManager = excludeManager; } this.results = { issues: [], stats: { critical: 0, high: 0, medium: 0, low: 0, info: 0 }, scannedFiles: 0 }; // 拡張機能のベースパスを保存 this.extensionBasePath = path.resolve(extensionPath); await this.scanDirectory(extensionPath); return this.results; } /** * ディレクトリを再帰的にスキャン */ async scanDirectory(dir, basePath = '') { // ディレクトリが拡張機能のベースパス内にあるか確認 const resolvedDir = path.resolve(dir); if (!resolvedDir.startsWith(this.extensionBasePath)) { // 拡張機能外のディレクトリはスキップ return; } const entries = await fs.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relativePath = path.join(basePath, entry.name); if (entry.isDirectory()) { // ExcludeManagerでチェック if (!this.excludeManager.shouldExcludeDirectory(relativePath)) { await this.scanDirectory(fullPath, relativePath); } } else if (entry.isFile()) { // ExcludeManagerでチェック if (!this.excludeManager.shouldExclude(relativePath)) { await this.scanFile(fullPath, relativePath); } } } } /** * ファイルをスキャン */ async scanFile(filePath, relativePath) { // ファイルが拡張機能のベースパス内にあるか確認 const resolvedPath = path.resolve(filePath); if (!resolvedPath.startsWith(this.extensionBasePath)) { // 拡張機能外のファイルはスキップ return; } const ext = path.extname(filePath).toLowerCase(); // スキャン対象のファイルタイプ if (['.js', '.html', '.json', '.css'].includes(ext)) { this.results.scannedFiles++; try { const content = await fs.promises.readFile(filePath, 'utf8'); // 各パターンカテゴリをチェック if (ext === '.js') { this.checkPatterns(content, relativePath, this.patterns.apiKeys); this.checkPatterns(content, relativePath, this.patterns.insecureStorage); this.checkPatterns(content, relativePath, this.patterns.dangerousFunctions); this.checkPatterns(content, relativePath, this.patterns.xssVulnerabilities); this.checkPatterns(content, relativePath, this.patterns.insecureCommunication); } else if (ext === '.html') { this.checkHTMLSecurity(content, relativePath); } else if (ext === '.json' && path.basename(filePath) === 'manifest.json') { await this.checkManifestSecurity(content, relativePath); } } catch (error) { // ファイル読み取りエラーは無視 } } } /** * パターンをチェック */ checkPatterns(content, filePath, patterns) { const lines = content.split('\n'); patterns.forEach(patternDef => { const matches = content.matchAll(new RegExp(patternDef.pattern, 'g')); for (const match of matches) { const lineNumber = this.getLineNumber(content, match.index); const line = lines[lineNumber - 1]; // 診断モードでファイル情報を出力 if (this.diagnosticMode && lineNumber > lines.length) { console.warn(`\n⚠️ Line number mismatch detected:`); console.warn(` File: ${filePath}`); console.warn(` Calculated line: ${lineNumber}`); console.warn(` Total lines in file: ${lines.length}`); console.warn(` Pattern: ${patternDef.name}`); DiagnosticHelper.printDiagnostics(filePath, lineNumber); } this.addIssue({ file: filePath, line: lineNumber, type: patternDef.name, severity: patternDef.severity, message: `${patternDef.name} detected`, code: line ? line.trim() : '', pattern: patternDef.pattern.toString() }); } }); } /** * HTMLのセキュリティチェック */ checkHTMLSecurity(content, filePath) { // インラインスクリプト const inlineScripts = content.match(/<script[^>]*>[\s\S]*?<\/script>/gi) || []; inlineScripts.forEach((script, index) => { if (!script.includes('src=')) { this.addIssue({ file: filePath, line: this.getLineNumber(content, content.indexOf(script)), type: 'Inline Script', severity: 'high', message: 'Inline scripts are not allowed in Chrome extensions', code: script.substring(0, 50) + '...' }); } }); // インラインイベントハンドラ const eventHandlers = content.match(/\son\w+\s*=\s*["'][^"']+["']/gi) || []; eventHandlers.forEach(handler => { this.addIssue({ file: filePath, line: this.getLineNumber(content, content.indexOf(handler)), type: 'Inline Event Handler', severity: 'high', message: 'Inline event handlers are not allowed', code: handler }); }); // 危険なリンク const dangerousLinks = content.match(/href\s*=\s*["']javascript:/gi) || []; dangerousLinks.forEach(link => { this.addIssue({ file: filePath, line: this.getLineNumber(content, content.indexOf(link)), type: 'JavaScript URL', severity: 'high', message: 'JavaScript URLs are not allowed', code: link }); }); } /** * manifest.jsonのセキュリティチェック */ async checkManifestSecurity(content, filePath) { try { const manifest = JSON.parse(content); // 過剰な権限 if (manifest.permissions) { const dangerousPermissions = [ 'debugger', 'pageCapture', 'privacy', 'proxy', 'system.cpu', 'system.memory', 'system.storage' ]; manifest.permissions.forEach(perm => { if (dangerousPermissions.includes(perm)) { this.addIssue({ file: filePath, line: 0, type: 'Dangerous Permission', severity: 'high', message: `Dangerous permission requested: ${perm}`, code: perm }); } }); // <all_urls>権限 if (manifest.permissions.includes('<all_urls>')) { this.addIssue({ file: filePath, line: 0, type: 'Overly Broad Permission', severity: 'critical', message: '<all_urls> permission is too broad', code: '<all_urls>' }); } } // 過剰なhost permissions if (manifest.host_permissions) { manifest.host_permissions.forEach(host => { if (host === '<all_urls>' || host === '*://*/*') { this.addIssue({ file: filePath, line: 0, type: 'Overly Broad Host Permission', severity: 'high', message: `Host permission too broad: ${host}`, code: host }); } }); } // 安全でないCSP if (manifest.content_security_policy) { const csp = manifest.content_security_policy.extension_pages || manifest.content_security_policy; if (csp.includes('unsafe-inline') || csp.includes('unsafe-eval')) { this.addIssue({ file: filePath, line: 0, type: 'Unsafe CSP', severity: 'critical', message: 'Content Security Policy contains unsafe directives', code: csp }); } } } catch (error) { // JSON解析エラーは無視 } } /** * 問題を追加 */ addIssue(issue) { this.results.issues.push(issue); this.results.stats[issue.severity]++; } /** * 行番号を取得 */ getLineNumber(content, index) { if (!content || index < 0) return 1; if (index >= content.length) return content.split('\n').length; // More accurate: count actual newlines up to position const lines = content.substring(0, index + 1).split('\n'); return lines.length; } /** * レポートを生成 */ generateReport() { const report = { summary: { totalIssues: this.results.issues.length, scannedFiles: this.results.scannedFiles, ...this.results.stats }, issuesByFile: {}, issuesBySeverity: { critical: [], high: [], medium: [], low: [], info: [] } }; // ファイル別に整理 this.results.issues.forEach(issue => { if (!report.issuesByFile[issue.file]) { report.issuesByFile[issue.file] = []; } report.issuesByFile[issue.file].push(issue); report.issuesBySeverity[issue.severity].push(issue); }); return report; } } module.exports = SecurityAnalyzer;