#!/usr/bin/env php 'text', 'severity' => 'low', 'output' => null, 'exclude' => [], 'include' => [], // Patterns to explicitly include (overrides exclude) 'config' => null, 'recursive-depth' => 10, 'no-colors' => false, 'quiet' => false, 'verbose' => false, 'lang' => 'ja', 'include-vendor' => false, // Include vendor directory 'include-tests' => false, // Include tests directory 'show-excluded' => false, // Show what is being excluded 'no-default-excludes' => false, // Don't apply default excludes 'context' => 0, // Number of context lines to show (0 = disabled) ]; private array $colors = [ 'reset' => "\033[0m", 'red' => "\033[31m", 'green' => "\033[32m", 'yellow' => "\033[33m", 'blue' => "\033[34m", 'magenta' => "\033[35m", 'cyan' => "\033[36m", 'white' => "\033[37m", 'bold' => "\033[1m", 'dim' => "\033[2m", // Syntax highlighting colors 'syn_keyword' => "\033[38;5;198m", // Pink/magenta for keywords 'syn_string' => "\033[38;5;113m", // Green for strings 'syn_variable' => "\033[38;5;208m", // Orange for variables 'syn_comment' => "\033[38;5;245m", // Gray for comments 'syn_number' => "\033[38;5;141m", // Purple for numbers 'syn_function' => "\033[38;5;81m", // Cyan for functions 'syn_class' => "\033[38;5;221m", // Yellow for class names 'syn_operator' => "\033[38;5;248m", // Light gray for operators 'syn_constant' => "\033[38;5;141m", // Purple for constants ]; public function run(array $argv): int { $args = $this->parseArguments($argv); if (isset($args['help']) || isset($args['h'])) { $this->showHelp(); return 0; } if (isset($args['version']) || isset($args['v'])) { $this->showVersion(); return 0; } $target = $args['_'][0] ?? '.'; if (!file_exists($target)) { $this->error("Target not found: {$target}"); return 1; } $this->options = array_merge($this->options, $this->extractOptions($args)); // Set language Messages::setLocale($this->options['lang']); if ($this->options['no-colors'] || !$this->isInteractive()) { $this->colors = array_fill_keys(array_keys($this->colors), ''); } return $this->analyze($target); } private function analyze(string $target): int { $startTime = microtime(true); // Load config file if exists $this->loadConfigFile($target); if (!$this->options['quiet']) { $this->printBanner(); $this->info(Messages::get('cli.analyzing', ['path' => $target])); $this->info(""); } // Build exclude patterns $excludePatterns = $this->buildExcludePatterns(); // Show excluded directories if requested if ($this->options['show-excluded']) { $this->showExcludedPatterns($excludePatterns, $target); } $config = [ 'recursive_depth' => (int) $this->options['recursive-depth'], 'exclude_patterns' => $excludePatterns, 'include_patterns' => $this->options['include'], ]; $linter = new SecurityLinter($config); try { if (is_dir($target)) { $vulnerabilities = $linter->analyzeDirectory($target); } else { $vulnerabilities = $linter->analyzeFile($target); } } catch (\Exception $e) { $this->error("Analysis failed: " . $e->getMessage()); if ($this->options['verbose']) { $this->error($e->getTraceAsString()); } return 1; } $vulnerabilities = $linter->getVulnerabilities($this->options['severity']); $report = $linter->generateReport($this->options['format'], $vulnerabilities); $stats = $this->calculateStats($vulnerabilities); if ($this->options['output']) { file_put_contents($this->options['output'], $report); if (!$this->options['quiet']) { $this->success(Messages::get('cli.report_written', ['path' => $this->options['output']])); } } else { if ($this->options['format'] === 'text') { $this->printTextReport($vulnerabilities, $stats); } else { echo $report; } } $elapsed = round(microtime(true) - $startTime, 2); if (!$this->options['quiet']) { $this->info(""); $this->info(Messages::get('cli.completed', ['time' => $elapsed])); } if ($stats['by_severity']['critical'] > 0 || $stats['by_severity']['high'] > 0) { return 2; } if ($stats['total'] > 0) { return 1; } return 0; } private function printTextReport(array $vulnerabilities, array $stats): void { if (empty($vulnerabilities)) { $this->success(Messages::get('cli.no_vulnerabilities')); return; } $grouped = [ 'critical' => [], 'high' => [], 'medium' => [], 'low' => [], ]; foreach ($vulnerabilities as $vuln) { $grouped[$vuln->getSeverity()][] = $vuln; } foreach (['critical', 'high', 'medium', 'low'] as $severity) { if (empty($grouped[$severity])) { continue; } $color = $this->getSeverityColor($severity); $count = count($grouped[$severity]); $label = Messages::get("severity.{$severity}"); echo "\n"; echo $this->colors['bold'] . $color; echo strtoupper($label) . " ({$count})"; echo $this->colors['reset'] . "\n"; echo str_repeat("─", 60) . "\n"; foreach ($grouped[$severity] as $vuln) { $this->printVulnerability($vuln); } } echo "\n" . str_repeat("═", 60) . "\n"; echo $this->colors['bold'] . Messages::get('cli.summary') . $this->colors['reset'] . "\n"; echo str_repeat("─", 60) . "\n"; $summaryItems = [ [Messages::get('severity.critical'), $stats['by_severity']['critical'], 'red'], [Messages::get('severity.high'), $stats['by_severity']['high'], 'yellow'], [Messages::get('severity.medium'), $stats['by_severity']['medium'], 'cyan'], [Messages::get('severity.low'), $stats['by_severity']['low'], 'dim'], ]; foreach ($summaryItems as [$label, $count, $color]) { $colorCode = $this->colors[$color] ?? ''; echo sprintf(" %-12s %s%d%s\n", $label . ":", $colorCode, $count, $this->colors['reset']); } echo str_repeat("─", 60) . "\n"; echo sprintf(" %-12s %s%d%s\n", Messages::get('cli.total') . ":", $this->colors['bold'], $stats['total'], $this->colors['reset']); } private function printVulnerability(Vulnerability $vuln): void { $color = $this->getSeverityColor($vuln->getSeverity()); echo "\n" . $color . $this->colors['bold']; echo "[{$vuln->getType()}]" . $this->colors['reset'] . " "; echo $vuln->getMessage() . "\n"; echo $this->colors['dim']; echo " 📍 {$vuln->getFile()}:{$vuln->getLine()}\n"; echo $this->colors['reset']; // Show code context if enabled if ($this->options['context'] > 0) { $this->printCodeContext($vuln->getFile(), $vuln->getLine(), $this->options['context']); } elseif ($vuln->getCode()) { echo $this->colors['cyan']; echo " 💻 " . $this->truncate($vuln->getCode(), 70) . "\n"; echo $this->colors['reset']; } $meta = []; if ($vuln->getCweId()) { $meta[] = $vuln->getCweId(); } if ($vuln->getOwaspCategory()) { $meta[] = $vuln->getOwaspCategory(); } if (!empty($meta)) { echo $this->colors['dim']; echo " 🏷️ " . implode(" | ", $meta) . "\n"; echo $this->colors['reset']; } if (!empty($vuln->getCallTrace()) && $this->options['verbose']) { echo $this->colors['magenta']; echo " 📚 " . Messages::get('cli.call_trace') . ":\n"; foreach ($vuln->getCallTrace() as $trace) { echo " → {$trace['function']} at {$trace['file']}:{$trace['line']}\n"; } echo $this->colors['reset']; } if ($vuln->getRecommendation()) { echo $this->colors['green']; echo " 💡 " . $vuln->getRecommendation() . "\n"; echo $this->colors['reset']; } } /** * Print code context around the vulnerable line */ private function printCodeContext(string $file, int $line, int $contextLines): void { // Handle Docker path mapping - try both /target/ prefix and without $filePath = $file; if (!file_exists($filePath) && str_starts_with($file, '/target/')) { $filePath = substr($file, 8); // Remove '/target/' prefix } if (!file_exists($filePath)) { return; } $lines = @file($filePath); if ($lines === false) { return; } $startLine = max(1, $line - $contextLines); $endLine = min(count($lines), $line + $contextLines); echo "\n"; echo $this->colors['dim'] . " ┌─ " . basename($file) . "\n"; for ($i = $startLine; $i <= $endLine; $i++) { $lineContent = rtrim($lines[$i - 1] ?? ''); $lineNum = str_pad((string)$i, 4, ' ', STR_PAD_LEFT); // Apply syntax highlighting $highlightedContent = $this->highlightPhpSyntax($lineContent); if ($i === $line) { // Highlight the vulnerable line with red background indicator echo $this->colors['red'] . $this->colors['bold']; echo " │ {$lineNum} ▶ "; echo $this->colors['reset']; echo $highlightedContent . "\n"; } else { echo $this->colors['dim']; echo " │ {$lineNum} "; echo $this->colors['reset']; echo $highlightedContent . "\n"; } } echo $this->colors['dim'] . " └─\n" . $this->colors['reset']; } private function getSeverityColor(string $severity): string { return match ($severity) { 'critical' => $this->colors['red'] . $this->colors['bold'], 'high' => $this->colors['yellow'], 'medium' => $this->colors['cyan'], 'low' => $this->colors['dim'], default => $this->colors['white'], }; } private function printBanner(): void { echo $this->colors['cyan'] . $this->colors['bold']; echo "╔════════════════════════════════════════════════════════════╗\n"; echo "║ " . Messages::get('cli.banner') . " v" . self::VERSION . " ║\n"; echo "╚════════════════════════════════════════════════════════════╝\n"; echo $this->colors['reset']; } private function showHelp(): void { $isJapanese = ($this->options['lang'] ?? 'ja') === 'ja'; $this->printBanner(); if ($isJapanese) { echo <<colors['bold']}使用方法:{$this->colors['reset']} security-lint [オプション] <パス> {$this->colors['bold']}引数:{$this->colors['reset']} <パス> 解析するファイルまたはディレクトリ (デフォルト: カレントディレクトリ) {$this->colors['bold']}出力オプション:{$this->colors['reset']} -h, --help このヘルプメッセージを表示 -v, --version バージョン情報を表示 -f, --format 出力形式: text, json, html, sarif, markdown (デフォルト: text) -s, --severity 報告する最小重大度: low, medium, high, critical (デフォルト: low) -o, --output 標準出力の代わりにファイルにレポートを書き込み -l, --lang 出力言語: ja, en (デフォルト: ja) -c, --context [N] 問題行の前後N行のコードスニペットを表示 (デフォルト: 3) --no-colors カラー出力を無効化 -q, --quiet 進捗出力を抑制 --verbose コールトレースを含む詳細情報を表示 {$this->colors['bold']}除外/包含オプション:{$this->colors['reset']} -e, --exclude 除外パターン (複数回使用可能) -i, --include 包含パターン (除外を上書き、複数回使用可能) --include-vendor vendor ディレクトリも解析 (依存関係のセキュリティ監査用) --include-tests tests ディレクトリも解析 --no-default-excludes デフォルト除外パターンを使用しない --show-excluded 除外されるパターンとディレクトリを表示 {$this->colors['bold']}解析オプション:{$this->colors['reset']} -d, --recursive-depth コール追跡の最大深度 (デフォルト: 10) {$this->colors['bold']}デフォルト除外パターン:{$this->colors['reset']} vendor/*, node_modules/*, storage/*, .git/*, tests/*, bootstrap/cache/*, public/vendor/*, cache/*, tmp/* {$this->colors['bold']}設定ファイル:{$this->colors['reset']} .security-lint.json をプロジェクトルートに配置することで設定を永続化できます。 例: {"exclude": ["custom/*"], "severity": "medium", "includeTests": true} {$this->colors['bold']}使用例:{$this->colors['reset']} security-lint app/ # app ディレクトリをスキャン security-lint -f json -o report.json # JSONレポートを生成 security-lint -s high # 高/クリティカルの問題のみ表示 security-lint -c # コードスニペット表示 (前後3行) security-lint -c 5 # コードスニペット表示 (前後5行) security-lint -e "tests/*" # tests ディレクトリを除外 security-lint --include-vendor # vendor も含めて解析 security-lint --show-excluded # 除外パターンを確認 security-lint -l en # 英語で出力 {$this->colors['bold']}終了コード:{$this->colors['reset']} 0 問題なし 1 問題あり (中/低重大度) 2 クリティカル/高重大度の問題あり {$this->colors['bold']}検出可能な脆弱性:{$this->colors['reset']} • XSS (クロスサイトスクリプティング) • SQLインジェクション • コマンドインジェクション • パストラバーサル • CSRF脆弱性 • 認証セキュリティの問題 • セッションセキュリティの問題 • 設定セキュリティの問題 HELP; } else { echo <<colors['bold']}USAGE:{$this->colors['reset']} security-lint [options] {$this->colors['bold']}ARGUMENTS:{$this->colors['reset']} File or directory to analyze (default: current directory) {$this->colors['bold']}OUTPUT OPTIONS:{$this->colors['reset']} -h, --help Show this help message -v, --version Show version information -f, --format Output format: text, json, html, sarif, markdown (default: text) -s, --severity Minimum severity to report: low, medium, high, critical (default: low) -o, --output Write report to file instead of stdout -l, --lang Output language: ja, en (default: ja) -c, --context [N] Show N lines of code context around issues (default: 3) --no-colors Disable colored output -q, --quiet Suppress progress output --verbose Show detailed information including call traces {$this->colors['bold']}EXCLUDE/INCLUDE OPTIONS:{$this->colors['reset']} -e, --exclude Exclude patterns (can be used multiple times) -i, --include Include patterns (overrides excludes, can be used multiple times) --include-vendor Also analyze vendor directory (for dependency security audit) --include-tests Also analyze tests directory --no-default-excludes Do not apply default exclude patterns --show-excluded Show excluded patterns and directories {$this->colors['bold']}ANALYSIS OPTIONS:{$this->colors['reset']} -d, --recursive-depth Maximum depth for call tracing (default: 10) {$this->colors['bold']}DEFAULT EXCLUDE PATTERNS:{$this->colors['reset']} vendor/*, node_modules/*, storage/*, .git/*, tests/*, bootstrap/cache/*, public/vendor/*, cache/*, tmp/* {$this->colors['bold']}CONFIGURATION FILE:{$this->colors['reset']} Place .security-lint.json in your project root to persist settings. Example: {"exclude": ["custom/*"], "severity": "medium", "includeTests": true} {$this->colors['bold']}EXAMPLES:{$this->colors['reset']} security-lint app/ # Scan app directory security-lint -f json -o report.json # Generate JSON report security-lint -s high # Only show high/critical issues security-lint -c # Show code snippets (3 lines context) security-lint -c 5 # Show code snippets (5 lines context) security-lint -e "tests/*" # Exclude tests directory security-lint --include-vendor # Include vendor in analysis security-lint --show-excluded # Show what is being excluded security-lint -l en # Output in English {$this->colors['bold']}EXIT CODES:{$this->colors['reset']} 0 No issues found 1 Issues found (medium/low severity) 2 Critical/high severity issues found {$this->colors['bold']}DETECTED VULNERABILITIES:{$this->colors['reset']} • XSS (Cross-Site Scripting) • SQL Injection • Command Injection • Path Traversal • CSRF vulnerabilities • Insecure Authentication • Session Security issues • Insecure Configuration HELP; } } private function showVersion(): void { echo Messages::get('cli.banner') . " v" . self::VERSION . "\n"; } private function parseArguments(array $argv): array { $args = ['_' => []]; $i = 1; // Options that take optional numeric values only $numericOnlyOptions = ['c', 'context']; while ($i < count($argv)) { $arg = $argv[$i]; if (str_starts_with($arg, '--')) { $option = substr($arg, 2); if (str_contains($option, '=')) { [$key, $value] = explode('=', $option, 2); $args[$key] = $value; } else { $nextArg = $argv[$i + 1] ?? null; $isNumericOnly = in_array($option, $numericOnlyOptions, true); if ($nextArg !== null && !str_starts_with($nextArg, '-')) { // For numeric-only options, only consume if next arg is numeric if ($isNumericOnly) { if (ctype_digit($nextArg)) { $args[$option] = $argv[++$i]; } else { $args[$option] = true; } } else { $args[$option] = $argv[++$i]; } } else { $args[$option] = true; } } } elseif (str_starts_with($arg, '-')) { $chars = substr($arg, 1); for ($j = 0; $j < strlen($chars); $j++) { $char = $chars[$j]; $isNumericOnly = in_array($char, $numericOnlyOptions, true); $nextArg = $argv[$i + 1] ?? null; if ($j === strlen($chars) - 1 && $nextArg !== null && !str_starts_with($nextArg, '-')) { // For numeric-only options, only consume if next arg is numeric if ($isNumericOnly) { if (ctype_digit($nextArg)) { $args[$char] = $argv[++$i]; } else { $args[$char] = true; } } else { $args[$char] = $argv[++$i]; } } else { $args[$char] = true; } } } else { $args['_'][] = $arg; } $i++; } return $args; } private function extractOptions(array $args): array { $options = []; $format = $args['format'] ?? $args['f'] ?? null; if ($format !== null && $format !== true) { $options['format'] = $format; } $severity = $args['severity'] ?? $args['s'] ?? null; if ($severity !== null && $severity !== true) { $options['severity'] = $severity; } $output = $args['output'] ?? $args['o'] ?? null; if ($output !== null && $output !== true) { $options['output'] = $output; } // Exclude patterns (can be specified multiple times) $exclude = $args['exclude'] ?? $args['e'] ?? []; if (!is_array($exclude)) { $exclude = [$exclude]; } $options['exclude'] = $exclude; // Include patterns (override excludes) $include = $args['include'] ?? $args['i'] ?? []; if (!is_array($include)) { $include = [$include]; } $options['include'] = $include; $depth = $args['recursive-depth'] ?? $args['d'] ?? null; if ($depth !== null && $depth !== true) { $options['recursive-depth'] = $depth; } $lang = $args['lang'] ?? $args['l'] ?? null; if ($lang !== null && $lang !== true) { $options['lang'] = $lang; } $options['no-colors'] = isset($args['no-colors']); $options['quiet'] = isset($args['quiet']) || isset($args['q']); $options['verbose'] = isset($args['verbose']); // New options $options['include-vendor'] = isset($args['include-vendor']); $options['include-tests'] = isset($args['include-tests']); $options['show-excluded'] = isset($args['show-excluded']); $options['no-default-excludes'] = isset($args['no-default-excludes']); // Context lines option $context = $args['context'] ?? $args['c'] ?? null; if ($context !== null) { $options['context'] = $context === true ? 3 : (int) $context; } return $options; } private function info(string $message): void { if (!$this->options['quiet']) { echo $message . "\n"; } } private function success(string $message): void { echo $this->colors['green'] . "✓ " . $message . $this->colors['reset'] . "\n"; } private function error(string $message): void { fwrite(STDERR, $this->colors['red'] . "✗ " . $message . $this->colors['reset'] . "\n"); } private function truncate(string $text, int $length): string { $text = str_replace(["\n", "\r", "\t"], ' ', $text); $text = preg_replace('/\s+/', ' ', $text); if (strlen($text) <= $length) { return $text; } return substr($text, 0, $length - 3) . '...'; } private function isInteractive(): bool { return function_exists('posix_isatty') && posix_isatty(STDOUT); } /** * Build the list of exclude patterns based on options */ private function buildExcludePatterns(): array { $patterns = []; // Add default excludes unless disabled if (!$this->options['no-default-excludes']) { $patterns = self::DEFAULT_EXCLUDES; // Remove vendor from excludes if --include-vendor is set if ($this->options['include-vendor']) { $patterns = array_filter($patterns, fn($p) => !str_starts_with($p, 'vendor')); } // Remove tests from excludes if --include-tests is set if ($this->options['include-tests']) { $patterns = array_filter($patterns, fn($p) => !str_starts_with($p, 'tests')); } } // Add user-specified excludes $patterns = array_merge($patterns, $this->options['exclude']); // Remove duplicates return array_unique(array_values($patterns)); } /** * Load configuration from .security-lint.json if it exists */ private function loadConfigFile(string $target): void { // Look for config file in target directory or current directory $configPaths = []; if (is_dir($target)) { $configPaths[] = rtrim($target, '/') . '/.security-lint.json'; } $configPaths[] = getcwd() . '/.security-lint.json'; $configPaths[] = getenv('HOME') . '/.security-lint.json'; foreach ($configPaths as $configPath) { if (file_exists($configPath)) { $this->loadConfig($configPath); if ($this->options['verbose']) { $this->info("Loaded config from: {$configPath}"); } break; } } } /** * Load and merge configuration from a JSON file */ private function loadConfig(string $path): void { $content = file_get_contents($path); $config = json_decode($content, true); if (!is_array($config)) { return; } // Merge config options (CLI options take precedence) $configMapping = [ 'format' => 'format', 'severity' => 'severity', 'exclude' => 'exclude', 'include' => 'include', 'recursiveDepth' => 'recursive-depth', 'recursive-depth' => 'recursive-depth', 'lang' => 'lang', 'includeVendor' => 'include-vendor', 'include-vendor' => 'include-vendor', 'includeTests' => 'include-tests', 'include-tests' => 'include-tests', 'noDefaultExcludes' => 'no-default-excludes', 'no-default-excludes' => 'no-default-excludes', ]; foreach ($configMapping as $jsonKey => $optionKey) { if (isset($config[$jsonKey])) { // Don't override if already set via CLI if ($optionKey === 'exclude' || $optionKey === 'include') { // Merge arrays $this->options[$optionKey] = array_merge( (array)$config[$jsonKey], $this->options[$optionKey] ); } elseif (!$this->wasSetViaCli($optionKey)) { $this->options[$optionKey] = $config[$jsonKey]; } } } } /** * Check if an option was explicitly set via CLI */ private function wasSetViaCli(string $option): bool { // Track which options were set via CLI static $defaults = [ 'format' => 'text', 'severity' => 'low', 'recursive-depth' => 10, 'lang' => 'ja', 'include-vendor' => false, 'include-tests' => false, 'no-default-excludes' => false, ]; return $this->options[$option] !== ($defaults[$option] ?? null); } /** * Show what directories/patterns are being excluded */ private function showExcludedPatterns(array $patterns, string $target): void { $isJapanese = $this->options['lang'] === 'ja'; echo $this->colors['cyan'] . $this->colors['bold']; echo $isJapanese ? "除外パターン:\n" : "Excluded patterns:\n"; echo $this->colors['reset']; foreach ($patterns as $pattern) { echo $this->colors['dim'] . " - {$pattern}" . $this->colors['reset'] . "\n"; } // Show actual excluded directories if target is a directory if (is_dir($target)) { $excluded = $this->findExcludedDirs($target, $patterns); if (!empty($excluded)) { echo "\n"; echo $this->colors['yellow']; echo $isJapanese ? "除外されるディレクトリ:\n" : "Excluded directories:\n"; echo $this->colors['reset']; $count = 0; foreach ($excluded as $dir) { if ($count >= 20) { $remaining = count($excluded) - 20; echo $this->colors['dim'] . " ... " . ($isJapanese ? "他 {$remaining} 件" : "and {$remaining} more") . $this->colors['reset'] . "\n"; break; } echo $this->colors['dim'] . " ✗ {$dir}" . $this->colors['reset'] . "\n"; $count++; } } } echo "\n"; } /** * Calculate statistics from vulnerabilities */ private function calculateStats(array $vulnerabilities): array { $stats = [ 'total' => count($vulnerabilities), 'by_severity' => ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0], 'by_type' => [], ]; foreach ($vulnerabilities as $vuln) { $severity = $vuln->getSeverity(); if (isset($stats['by_severity'][$severity])) { $stats['by_severity'][$severity]++; } $type = $vuln->getType(); $stats['by_type'][$type] = ($stats['by_type'][$type] ?? 0) + 1; } return $stats; } /** * Apply PHP syntax highlighting to a line of code */ private function highlightPhpSyntax(string $code): string { if (empty($this->colors['syn_keyword'])) { // Colors disabled return $code; } $reset = $this->colors['reset']; // PHP keywords $keywords = [ 'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch', 'class', 'clone', 'const', 'continue', 'declare', 'default', 'die', 'do', 'echo', 'else', 'elseif', 'empty', 'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'enum', 'eval', 'exit', 'extends', 'final', 'finally', 'fn', 'for', 'foreach', 'function', 'global', 'goto', 'if', 'implements', 'include', 'include_once', 'instanceof', 'insteadof', 'interface', 'isset', 'list', 'match', 'namespace', 'new', 'or', 'print', 'private', 'protected', 'public', 'readonly', 'require', 'require_once', 'return', 'static', 'switch', 'throw', 'trait', 'try', 'unset', 'use', 'var', 'while', 'xor', 'yield', 'true', 'false', 'null', 'self', 'parent', ]; $result = ''; $length = strlen($code); $i = 0; while ($i < $length) { // Check for single-line comment if ($i < $length - 1 && $code[$i] === '/' && $code[$i + 1] === '/') { $comment = substr($code, $i); $result .= $this->colors['syn_comment'] . $comment . $reset; break; } // Check for multi-line comment start if ($i < $length - 1 && $code[$i] === '/' && $code[$i + 1] === '*') { $endPos = strpos($code, '*/', $i + 2); if ($endPos !== false) { $comment = substr($code, $i, $endPos - $i + 2); $result .= $this->colors['syn_comment'] . $comment . $reset; $i = $endPos + 2; continue; } else { $comment = substr($code, $i); $result .= $this->colors['syn_comment'] . $comment . $reset; break; } } // Check for strings (single quote) if ($code[$i] === "'") { $j = $i + 1; while ($j < $length) { if ($code[$j] === "'" && ($j === $i + 1 || $code[$j - 1] !== '\\')) { break; } $j++; } $str = substr($code, $i, $j - $i + 1); $result .= $this->colors['syn_string'] . $str . $reset; $i = $j + 1; continue; } // Check for strings (double quote) if ($code[$i] === '"') { $j = $i + 1; while ($j < $length) { if ($code[$j] === '"' && ($j === $i + 1 || $code[$j - 1] !== '\\')) { break; } $j++; } $str = substr($code, $i, $j - $i + 1); $result .= $this->colors['syn_string'] . $str . $reset; $i = $j + 1; continue; } // Check for variables if ($code[$i] === '$') { $j = $i + 1; while ($j < $length && (ctype_alnum($code[$j]) || $code[$j] === '_')) { $j++; } $var = substr($code, $i, $j - $i); $result .= $this->colors['syn_variable'] . $var . $reset; $i = $j; continue; } // Check for numbers if (ctype_digit($code[$i]) || ($code[$i] === '.' && $i + 1 < $length && ctype_digit($code[$i + 1]))) { $j = $i; while ($j < $length && (ctype_digit($code[$j]) || $code[$j] === '.' || $code[$j] === 'e' || $code[$j] === 'E')) { $j++; } $num = substr($code, $i, $j - $i); $result .= $this->colors['syn_number'] . $num . $reset; $i = $j; continue; } // Check for words (keywords, functions, class names) if (ctype_alpha($code[$i]) || $code[$i] === '_') { $j = $i; while ($j < $length && (ctype_alnum($code[$j]) || $code[$j] === '_')) { $j++; } $word = substr($code, $i, $j - $i); $wordLower = strtolower($word); // Check if it's a keyword if (in_array($wordLower, $keywords, true)) { $result .= $this->colors['syn_keyword'] . $word . $reset; } // Check if it's a function call (followed by parenthesis) elseif ($j < $length && $code[$j] === '(') { $result .= $this->colors['syn_function'] . $word . $reset; } // Check if it's a constant (all uppercase) elseif ($word === strtoupper($word) && strlen($word) > 1) { $result .= $this->colors['syn_constant'] . $word . $reset; } // Check if it looks like a class name (PascalCase after new/extends/implements) elseif (preg_match('/^[A-Z][a-zA-Z0-9_]*$/', $word)) { $result .= $this->colors['syn_class'] . $word . $reset; } else { $result .= $word; } $i = $j; continue; } // Check for operators if (in_array($code[$i], ['=>', '->', '::', '=', '+', '-', '*', '/', '%', '.', '<', '>', '!', '&', '|', '^', '~', '?', ':'], true)) { $result .= $this->colors['syn_operator'] . $code[$i] . $reset; $i++; continue; } // Default: output character as-is $result .= $code[$i]; $i++; } return $result; } /** * Find directories that would be excluded */ private function findExcludedDirs(string $target, array $patterns): array { $excluded = []; $basePath = realpath($target); if (!$basePath) { return []; } try { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); $iterator->setMaxDepth(3); // Only check 3 levels deep for performance foreach ($iterator as $file) { if (!$file->isDir()) { continue; } $path = $file->getPathname(); $relativePath = str_replace($basePath . '/', '', $path); foreach ($patterns as $pattern) { if (fnmatch($pattern, $relativePath) || fnmatch($pattern, $relativePath . '/')) { $excluded[] = $relativePath; break; } } } } catch (\Exception $e) { // Ignore errors when scanning directories } return array_unique($excluded); } } $cli = new SecurityLintCLI(); exit($cli->run($argv));