1086 lines
39 KiB
Plaintext
1086 lines
39 KiB
Plaintext
|
|
#!/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 = '1.0.0';
|
||
|
|
|
||
|
|
/** @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) {
|
||
|
|
$options['format'] = $format;
|
||
|
|
}
|
||
|
|
|
||
|
|
$severity = $args['severity'] ?? $args['s'] ?? null;
|
||
|
|
if ($severity) {
|
||
|
|
$options['severity'] = $severity;
|
||
|
|
}
|
||
|
|
|
||
|
|
$output = $args['output'] ?? $args['o'] ?? null;
|
||
|
|
if ($output) {
|
||
|
|
$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) {
|
||
|
|
$options['recursive-depth'] = $depth;
|
||
|
|
}
|
||
|
|
|
||
|
|
$lang = $args['lang'] ?? $args['l'] ?? null;
|
||
|
|
if ($lang) {
|
||
|
|
$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));
|