chrome-extension-test-framework
Version:
Universal testing framework for Chrome extensions - Fast static analysis without browser dependencies
549 lines (460 loc) • 19.5 kB
JavaScript
/**
* ContextAwareDetector - コンテキストを考慮した検出
*
* Issue #9: Too many false positives
* コードのコンテキストを理解して、誤検知を減らすためのモジュール
*/
const ChromePatternRecognizer = require('./ChromePatternRecognizer');
class ContextAwareDetector {
constructor() {
// Chrome拡張機能パターン認識器
this.chromeRecognizer = new ChromePatternRecognizer();
// 安全なパターンのリスト
this.safePatterns = {
innerHTML: [
// 空文字列への設定
/innerHTML\s*=\s*['"]\s*['"]/,
// DOMPurifyなどのサニタイザー
/DOMPurify\.sanitize/,
// Chrome i18n API
/chrome\.i18n\.getMessage/,
// フレームワーク固有の安全なパターン
/dangerouslySetInnerHTML\s*=\s*{\s*{\s*__html:/, // React
/v-html\s*=/, // Vue
/\[innerHTML\]\s*=/, // Angular
/sanitize.*innerHTML/i,
/createSanitizer/
],
localStorage: [
// 開発環境チェック
/if\s*\(.*NODE_ENV.*development.*\)/,
// フォールバック処理
/\|\|\s*localStorage/,
// try-catch内
/try\s*{[^}]*localStorage/
]
};
// フレームワーク検出パターン
this.frameworkPatterns = {
react: [
/import.*from\s+['"]react['"]/,
/React\.\w+/,
/jsx|tsx$/,
/dangerouslySetInnerHTML/
],
vue: [
/import.*from\s+['"]vue['"]/,
/new\s+Vue\s*\(/,
/v-html|v-model|v-for|v-if/,
/\.vue$/
],
angular: [
/import.*from\s+['"]@angular/,
/@Component\s*\(/,
/\[(innerHTML|innerText)\]/,
/\.component\.ts$/
]
};
}
/**
* コメント内かどうかチェック
*/
isInComment(content, position) {
// 行の開始位置を見つける
const lineStart = content.lastIndexOf('\n', position) + 1;
const lineEnd = content.indexOf('\n', position);
const line = content.substring(lineStart, lineEnd === -1 ? content.length : lineEnd);
// 単一行コメント
const commentIndex = line.indexOf('//');
if (commentIndex !== -1 && commentIndex < position - lineStart) {
return true;
}
// 複数行コメント
const beforePosition = content.substring(0, position);
const lastCommentStart = beforePosition.lastIndexOf('/*');
const lastCommentEnd = beforePosition.lastIndexOf('*/');
return lastCommentStart > lastCommentEnd;
}
/**
* 文字列リテラル内かどうかチェック
*/
isInStringLiteral(content, position) {
const beforePosition = content.substring(0, position);
// 各種クォートの開閉をカウント
let inSingleQuote = false;
let inDoubleQuote = false;
let inTemplate = false;
let escaped = false;
for (let i = 0; i < beforePosition.length; i++) {
const char = beforePosition[i];
if (char === '\\' && !escaped) {
escaped = true;
continue;
}
if (!escaped) {
if (char === "'" && !inDoubleQuote && !inTemplate) {
inSingleQuote = !inSingleQuote;
} else if (char === '"' && !inSingleQuote && !inTemplate) {
inDoubleQuote = !inDoubleQuote;
} else if (char === '`' && !inSingleQuote && !inDoubleQuote) {
inTemplate = !inTemplate;
}
}
escaped = false;
}
return inSingleQuote || inDoubleQuote || inTemplate;
}
/**
* 行番号を取得
*/
getLineNumber(content, position) {
if (!content || position < 0) return 1;
if (position >= content.length) return content.split('\n').length;
// More accurate: count actual newlines up to position
const lines = content.substring(0, position + 1).split('\n');
return lines.length;
}
/**
* フレームワークを検出
*/
detectFramework(content, filePath = '') {
const detectedFrameworks = new Set();
for (const [framework, patterns] of Object.entries(this.frameworkPatterns)) {
for (const pattern of patterns) {
if (pattern.test(content) || pattern.test(filePath)) {
detectedFrameworks.add(framework);
break;
}
}
}
return Array.from(detectedFrameworks);
}
/**
* フレームワーク固有の安全なパターンかチェック
*/
isFrameworkSafePattern(content, position, filePath = '') {
const frameworks = this.detectFramework(content, filePath);
// フレームワークが検出されなくても、安全なパターンをチェック
const context = content.substring(Math.max(0, position - 200), position + 200);
// React の安全なパターン
if (/dangerouslySetInnerHTML\s*=\s*{\s*{\s*__html:/.test(context)) {
return true;
}
// Vue の安全なパターン
if (/v-html\s*=/.test(context)) {
return true;
}
// Angular の安全なパターン
if (/\[innerHTML\]\s*=/.test(context)) {
return true;
}
// サニタイザーの使用
if (/sanitize|DOMPurify|dompurify|xss/i.test(context)) {
return true;
}
return false;
}
/**
* 列番号を取得
*/
getColumnNumber(content, position) {
const lineStart = content.lastIndexOf('\n', position) + 1;
return position - lineStart + 1;
}
/**
* 行の内容を取得
*/
getLineContent(content, position) {
const lineStart = content.lastIndexOf('\n', position) + 1;
const lineEnd = content.indexOf('\n', position);
return content.substring(lineStart, lineEnd === -1 ? content.length : lineEnd);
}
/**
* 代入される値を取得
*/
getAssignedValue(content, position) {
const afterAssignment = content.substring(position);
const match = afterAssignment.match(/=\s*([^;]+);?/);
return match ? match[1].trim() : '';
}
/**
* innerHTMLの検出
*/
detectUnsafeInnerHTML(content, filePath) {
const issues = [];
const regex = /\.innerHTML\s*=\s*/g;
let match;
// ファイルのコンテキストを分析
const extensionContext = this.chromeRecognizer.analyzeExtensionContext(filePath, content);
while ((match = regex.exec(content)) !== null) {
const position = match.index;
const lineNumber = this.getLineNumber(content, position);
const lineContent = this.getLineContent(content, position);
// コンテキストをチェック
if (this.isInComment(content, position)) {
continue;
}
if (this.isInStringLiteral(content, position)) {
continue;
}
// Chrome拡張機能の安全なパターンかチェック
if (this.chromeRecognizer.isSafeInnerHTMLUsage(content, position)) {
continue;
}
// フレームワーク固有の安全なパターンかチェック
if (this.isFrameworkSafePattern(content, position, filePath)) {
continue;
}
// 代入される値を取得
const assignedValue = this.getAssignedValue(content, position);
// 安全なパターンかチェック
const isSafe = this.isSafeInnerHTMLAssignment(assignedValue, content);
const severity = this.getInnerHTMLSeverity(assignedValue, filePath, extensionContext);
if (!isSafe && severity !== 'ignore') {
issues.push({
type: 'innerHTML',
severity: severity,
line: lineNumber,
column: this.getColumnNumber(content, position),
message: `Potentially unsafe innerHTML assignment`,
context: lineContent.trim(),
assignedValue: assignedValue,
suggestion: 'Consider using textContent or a sanitization library like DOMPurify',
extensionContext: extensionContext.type
});
}
}
return issues;
}
/**
* innerHTMLの代入が安全かどうか判定
*/
isSafeInnerHTMLAssignment(assignedValue, content) {
// 空文字列
if (assignedValue === '""' || assignedValue === "''" || assignedValue === '``') {
return true;
}
// DOMPurifyなどのサニタイザー
if (/DOMPurify\.sanitize|sanitizeHTML|purify\(/.test(assignedValue)) {
return true;
}
// Chrome i18n API
if (assignedValue.includes('chrome.i18n.getMessage')) {
return true;
}
// 定数文字列(HTMLタグを含まない、かつテンプレートリテラルでない)
if (/^['"`][^'"`<>]*['"`]$/.test(assignedValue)) {
// テンプレートリテラルの場合は安全ではない
if (assignedValue.startsWith('`') && assignedValue.includes('${')) {
return false;
}
return true;
}
return false;
}
/**
* innerHTMLの深刻度を判定
*/
getInnerHTMLSeverity(assignedValue, filePath, extensionContext = {}) {
// Chrome拡張機能のコンテキストに基づく判定
if (extensionContext.isContentScript) {
// コンテンツスクリプトでは厳しく判定
if (assignedValue.includes('${') || /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(assignedValue)) {
return 'high';
}
}
// Chrome i18n APIの使用は安全
if (assignedValue.includes('chrome.i18n.getMessage')) {
return 'ignore';
}
// テンプレートリテラルで動的な値
if (assignedValue.includes('${')) {
return 'high';
}
// 変数の直接代入
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(assignedValue)) {
return 'high';
}
// 関数呼び出しの結果
if (assignedValue.includes('(') && assignedValue.includes(')')) {
// DOMPurifyなどのサニタイザー
if (/(?:DOMPurify|sanitize|purify)/.test(assignedValue)) {
return 'ignore';
}
return 'medium';
}
// 文字列リテラル
if (/^['"`].*['"`]$/.test(assignedValue)) {
return 'low';
}
return 'medium';
}
/**
* localStorageの検出
*/
detectLocalStorageUsage(content, filePath) {
const issues = [];
const regex = /localStorage\.(getItem|setItem)\s*\(/g;
let match;
// ファイルのコンテキストを分析
const extensionContext = this.chromeRecognizer.analyzeExtensionContext(filePath, content);
while ((match = regex.exec(content)) !== null) {
const position = match.index;
const lineNumber = this.getLineNumber(content, position);
const lineContent = this.getLineContent(content, position);
const method = match[1];
// コンテキストをチェック
if (this.isInComment(content, position)) {
continue;
}
if (this.isInStringLiteral(content, position)) {
continue;
}
// Chrome拡張機能の安全なパターンかチェック
if (this.chromeRecognizer.isSafeStorageUsage(content, lineContent, filePath)) {
continue;
}
const severity = this.getLocalStorageSeverity(content, position, filePath, extensionContext);
if (severity !== 'ignore') {
issues.push({
type: 'localStorage',
severity: severity,
line: lineNumber,
column: this.getColumnNumber(content, position),
message: `Use of localStorage.${method}`,
context: lineContent.trim(),
suggestion: 'Consider using chrome.storage API for Chrome extensions',
extensionContext: extensionContext.type
});
}
}
return issues;
}
/**
* localStorageの深刻度を判定
*/
getLocalStorageSeverity(content, position, filePath, extensionContext = {}) {
// ファイル名に基づく判定
if (filePath.includes('test') || filePath.includes('spec')) {
return 'ignore';
}
// Chrome拡張機能のコンテキストに基づく判定
if (extensionContext.isBackground) {
// バックグラウンドでのlocalStorage使用は非推奨
return 'high';
}
if (extensionContext.isContentScript) {
// コンテンツスクリプトでは使用不可
return 'high';
}
// 周辺のコードを確認
const context = content.substring(Math.max(0, position - 200), position + 200);
// 開発環境のチェック
if (/if\s*\(.*(?:DEBUG|DEV|development).*\)/.test(context)) {
return 'low';
}
// try-catch内
if (/try\s*{[^}]*localStorage/.test(context)) {
return 'low';
}
// chrome.storage への移行コメント
if (/\/\/.*(?:TODO|FIXME).*chrome\.storage/.test(context)) {
return 'medium';
}
// ポップアップやオプションページでは警告レベル
if (extensionContext.isPopup || extensionContext.isOptions) {
return 'medium';
}
return 'medium';
}
/**
* console使用の検出
*/
detectConsoleUsage(content, filePath) {
const issues = [];
const regex = /console\.(log|error|warn|info|debug)\s*\(/g;
let match;
// ファイルのコンテキストを分析
const extensionContext = this.chromeRecognizer.analyzeExtensionContext(filePath, content);
while ((match = regex.exec(content)) !== null) {
const position = match.index;
const lineNumber = this.getLineNumber(content, position);
const lineContent = this.getLineContent(content, position);
const method = match[1];
// コンテキストをチェック
if (this.isInComment(content, position)) {
continue;
}
if (this.isInStringLiteral(content, position)) {
continue;
}
// Chrome拡張機能では、エラーハンドリングのためのconsole.errorは許可
if (method === 'error' && this.isErrorHandling(content, position)) {
continue;
}
const severity = this.getConsoleSeverity(content, position, method, filePath, extensionContext);
if (severity !== 'ignore') {
issues.push({
type: 'console',
severity: severity,
line: lineNumber,
column: this.getColumnNumber(content, position),
message: `console.${method} usage detected`,
context: lineContent.trim(),
suggestion: 'Remove console statements from production code',
extensionContext: extensionContext.type
});
}
}
return issues;
}
/**
* consoleの深刻度を判定
*/
getConsoleSeverity(content, position, method, filePath, extensionContext = {}) {
// エラーハンドリングのconsole.errorは許可
if (method === 'error') {
const context = content.substring(Math.max(0, position - 100), position);
if (/catch\s*\([^)]*\)\s*{/.test(context)) {
return 'ignore';
}
// Chrome拡張機能のエラーハンドリング
if (/chrome\.runtime\.lastError/.test(context)) {
return 'ignore';
}
}
// 開発ファイル
if (filePath.match(/\.(dev|test|spec)\./)) {
return 'ignore';
}
// 開発ツールページでは許可
if (extensionContext.isDevtools) {
return 'ignore';
}
// デバッグ条件
const context = content.substring(Math.max(0, position - 200), position + 50);
if (/if\s*\(.*(?:DEBUG|DEV|development).*\)/.test(context)) {
return 'low';
}
// バックグラウンドスクリプトでのデバッグログ
if (extensionContext.isBackground && method === 'log') {
return 'medium'; // 本番環境では削除すべき
}
// console.warnは警告レベル
if (method === 'warn') {
return 'low';
}
return 'medium';
}
/**
* エラーハンドリングコンテキストかチェック
*/
isErrorHandling(content, position) {
const context = content.substring(Math.max(0, position - 200), position + 100);
return /catch\s*\([^)]*\)\s*{/.test(context) ||
/\.catch\s*\(/.test(context) ||
/chrome\.runtime\.lastError/.test(context) ||
/\.addEventListener\s*\(\s*['"]error['"]/.test(context);
}
}
module.exports = ContextAwareDetector;