Files

1086 lines
39 KiB
Plaintext
Raw Permalink Normal View History

#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* PHP/Laravel Security Linter CLI
*/
$autoloaderPaths = [
__DIR__ . '/../vendor/autoload.php',
__DIR__ . '/../../../autoload.php',
];
$autoloaderFound = false;
foreach ($autoloaderPaths as $path) {
if (file_exists($path)) {
require $path;
$autoloaderFound = true;
break;
}
}
if (!$autoloaderFound) {
fwrite(STDERR, "Error: Could not find autoloader. Please run 'composer install'.\n");
exit(1);
}
use SecurityLinter\SecurityLinter;
use SecurityLinter\Report\Vulnerability;
use SecurityLinter\I18n\Messages;
/**
* CLI Application
*/
class SecurityLintCLI
{
private const VERSION = '0.0.1';
/** @var array Default directories/patterns to exclude */
private const DEFAULT_EXCLUDES = [
'vendor/*',
'node_modules/*',
'storage/*',
'storage/framework/*',
'bootstrap/cache/*',
'public/vendor/*',
'public/build/*',
'.git/*',
'.svn/*',
'.idea/*',
'.vscode/*',
'cache/*',
'tmp/*',
'temp/*',
'logs/*',
'*.log',
'*.cache',
'tests/*', // Exclude tests by default (configurable)
'test-samples/*', // Exclude test samples
'test-projects/*', // Exclude cloned test projects
'database/migrations/*', // Migration files are usually safe
];
private array $options = [
'format' => '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 <<<HELP
{$this->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 <<<HELP
{$this->colors['bold']}USAGE:{$this->colors['reset']}
security-lint [options] <path>
{$this->colors['bold']}ARGUMENTS:{$this->colors['reset']}
<path> 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));