commit 628029089881ba14199df0fc4b35aea8b57c536a Author: Yutaka Kurosaki Date: Sat Jan 31 15:18:53 2026 +0900 Initial commit: PHP/Laravel Security Linter v1.0.0 A static security analysis tool for PHP and Laravel applications with recursive taint analysis capabilities. Features: - Comprehensive vulnerability detection (XSS, SQL Injection, Command Injection, Path Traversal, CSRF, Authentication issues) - Recursive taint analysis across function calls - Blade template analysis with context-aware XSS detection - Smart escape detection and escape bypass detection - Syntax highlighting in terminal output - Multi-language support (Japanese/English) - Docker support for easy deployment - Multiple output formats (text, JSON, HTML, SARIF, Markdown) - CI/CD integration ready (GitHub Actions, GitLab CI) Co-Authored-By: Claude Opus 4.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c97758d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Git +.git +.gitignore + +# Test projects +test-projects/ +test-samples/ + +# Documentation (keep docs/ for inline help) +*.md +!docs/*.md + +# Development files +.security-lint.json.example +.idea/ +.vscode/ + +# Composer cache +vendor/ + +# Docker files (avoid recursion) +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b00598d --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Dependencies +/vendor/ +composer.phar + +# Claude Code +.claude/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Test projects (cloned for testing) +/test-projects/ +/test-samples/ + +# Reports and outputs +*.log +/reports/ +report.json +report.html +report.md +report.sarif + +# Docker +.docker/ + +# PHPUnit / Testing +.phpunit.result.cache +.phpunit.cache/ +/coverage/ +.coverage + +# Temporary files +*.tmp +*.temp +*.cache + +# Environment +.env +.env.local +.env.*.local + +# Project config (use .security-lint.json.example as template) +.security-lint.json + +# Build artifacts +/build/ +/dist/ diff --git a/.security-lint.json.example b/.security-lint.json.example new file mode 100644 index 0000000..a4438a9 --- /dev/null +++ b/.security-lint.json.example @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/security-linter/php-laravel/main/schema/config.json", + "$comment": "Example configuration file for PHP/Laravel Security Linter", + + "severity": "low", + "format": "text", + "lang": "ja", + "recursiveDepth": 10, + + "exclude": [ + "custom/legacy/*", + "app/Console/Commands/*", + "resources/views/emails/*" + ], + + "include": [ + "vendor/my-company/*" + ], + + "includeVendor": false, + "includeTests": false, + "noDefaultExcludes": false +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3dc082b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM php:8.3-cli-alpine + +LABEL maintainer="Security Linter Team" +LABEL description="PHP/Laravel Security Linter - Static security analysis tool" +LABEL version="1.0.0" + +# Build arguments +ARG PHP_MEMORY_LIMIT=1024M + +# Install composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app/security-linter + +# Copy composer files first for better caching +COPY composer.json composer.lock* ./ + +# Install dependencies +RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress + +# Copy source code +COPY src/ ./src/ +COPY bin/ ./bin/ +COPY docs/ ./docs/ + +# Make binary executable, set memory limit, and create symlink +RUN chmod +x bin/security-lint \ + && echo "memory_limit=${PHP_MEMORY_LIMIT}" > /usr/local/etc/php/conf.d/memory.ini \ + && ln -s /app/security-linter/bin/security-lint /usr/local/bin/security-lint + +# Default working directory for target code +WORKDIR /target + +# Set entrypoint +ENTRYPOINT ["security-lint"] + +# Default command (show help) +CMD ["--help"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..23b93ce --- /dev/null +++ b/README.md @@ -0,0 +1,618 @@ +# PHP/Laravel Security Linter + +**[English](#english) | [日本語](#japanese)** + +--- + + +## English + +A static security analysis tool for PHP and Laravel applications. +Detects vulnerabilities through recursive taint analysis by tracking data flow across function calls. + +### Features + +- **Comprehensive Vulnerability Detection**: XSS, SQL Injection, Command Injection, Path Traversal, and more +- **Recursive Taint Analysis**: Tracks how user input propagates through function calls +- **Blade Template Analysis**: Laravel-specific `{!! !!}` syntax and context-aware XSS detection +- **Smart Escape Detection**: Recognizes escape functions like `htmlspecialchars()` +- **Escape Bypass Detection**: Detects escape invalidation by functions like `html_entity_decode()` +- **Syntax Highlighting**: Color-coded code snippets in terminal output +- **Multi-language Support**: Japanese/English message output + +### Installation + +#### Method 1: Docker (Recommended) + +No PHP or Composer environment required. + +```bash +# Clone the repository +git clone https://github.com/your-org/php-laravel-security-linter.git +cd php-laravel-security-linter + +# Install (builds Docker image and installs command) +./install.sh +``` + +After installation, use the `php-security-lint` command: + +```bash +# Run in your project directory +cd /path/to/your/laravel-project +php-security-lint . +php-security-lint app/ -s high +``` + +#### Method 2: Direct Execution + +Requires PHP 8.1+ and Composer. + +```bash +git clone https://github.com/your-org/php-laravel-security-linter.git +cd php-laravel-security-linter +composer install +php bin/security-lint /path/to/target +``` + +### Usage + +```bash +# Analyze a single file +php bin/security-lint path/to/file.php + +# Analyze a directory +php bin/security-lint app/ + +# Show high severity only +php bin/security-lint app/ -s high + +# Show code context (3 lines) +php bin/security-lint app/ -c + +# Show code context (5 lines) +php bin/security-lint app/ -c 5 + +# Output as JSON +php bin/security-lint app/ -f json -o report.json + +# Output in English +php bin/security-lint app/ -l en +``` + +### Options + +#### Output Options + +| Option | Description | +|--------|-------------| +| `-f, --format` | Output format: text, json, html, sarif, markdown | +| `-s, --severity` | Minimum severity: low, medium, high, critical | +| `-o, --output` | Output to file | +| `-l, --lang` | Language: ja, en | +| `-c, --context [N]` | Show N lines of code context (default: 3) | +| `--verbose` | Show detailed information | +| `-q, --quiet` | Suppress progress output | +| `--no-colors` | Disable colored output | + +#### Exclude/Include Options + +| Option | Description | +|--------|-------------| +| `-e, --exclude` | Exclude pattern (can be used multiple times) | +| `-i, --include` | Include pattern (overrides exclude) | +| `--include-vendor` | Also analyze vendor directory | +| `--include-tests` | Also analyze tests directory | +| `--no-default-excludes` | Don't use default exclude patterns | +| `--show-excluded` | Show excluded patterns | + +#### Analysis Options + +| Option | Description | +|--------|-------------| +| `-d, --recursive-depth` | Recursive analysis depth (default: 10) | + +### Default Exclude Patterns + +The following directories/patterns are excluded by default: + +- `vendor/*` - Composer dependencies +- `node_modules/*` - npm dependencies +- `storage/*` - Laravel storage +- `bootstrap/cache/*` - Laravel cache +- `public/vendor/*` - Public assets +- `.git/*`, `.svn/*` - Version control +- `tests/*` - Test files +- `cache/*`, `tmp/*`, `temp/*` - Temporary files + +To include these in analysis, use `--include-vendor` or `--include-tests`, or disable all with `--no-default-excludes`. + +### Configuration File + +Place `.security-lint.json` in your project root to persist settings: + +```json +{ + "severity": "medium", + "format": "text", + "lang": "en", + "exclude": ["custom/legacy/*"], + "include": ["vendor/my-company/*"], + "includeVendor": false, + "includeTests": false +} +``` + +### Detectable Vulnerabilities + +#### XSS (Cross-Site Scripting) + +- Blade `{!! !!}` raw output +- Output within JavaScript context +- Output within event handler attributes +- URL context (javascript: URLs) +- Style injection +- Template injection +- Escape bypass functions +- Dangerous hardcoded HTML + +#### SQL Injection + +- Tainted data in `DB::raw()` +- Query construction via string concatenation +- Direct PDO/MySQLi queries +- Laravel Query Builder Raw methods + +#### Command Injection + +- `exec()`, `shell_exec()`, `system()`, etc. +- `eval()`, `create_function()`, etc. +- Dynamic file includes +- Improper Symfony Process usage + +#### Path Traversal + +- Tainted paths in file operation functions +- Tainted paths in Laravel Storage +- File download/upload + +#### Authentication Security + +- Weak hash algorithms (MD5, SHA1) +- Hardcoded credentials +- Timing-vulnerable comparisons + +#### CSRF/Session + +- Missing CSRF tokens +- Insecure session configuration +- Session fixation + +#### Configuration Security + +- Debug output (phpinfo, var_dump) +- Insecure unserialize +- Sensitive information logging + +### Output Example + +``` +╔════════════════════════════════════════════════════════════╗ +║ PHP/Laravel Security Linter v1.0.0 ║ +╚════════════════════════════════════════════════════════════╝ +Analyzing: app/ + +HIGH (2) +──────────────────────────────────────────────────────────── + +[XSS] Blade's {!! !!} raw output may cause XSS vulnerability. + 📍 resources/views/user.blade.php:15 + + ┌─ user.blade.php + │ 12 + └─ + 🏷️ CWE-79 | A7:2017-XSS + 💡 Use auto-escaped {{ }} instead. + +════════════════════════════════════════════════════════════ +Summary +──────────────────────────────────────────────────────────── + Critical: 0 + High: 2 + Medium: 0 + Low: 0 +──────────────────────────────────────────────────────────── + Total: 2 + +Completed in 0.15 seconds +``` + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | No issues found | +| 1 | Medium/low severity issues found | +| 2 | Critical/high severity issues found | + +### CI/CD Integration + +#### GitHub Actions (Docker) + +```yaml +name: Security Scan + +on: [push, pull_request] + +jobs: + security-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Security Linter + run: | + docker run --rm -v ${{ github.workspace }}:/target \ + ghcr.io/your-org/php-security-linter:latest \ + /target -s high -f sarif -o /target/security.sarif + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: security.sarif +``` + +#### GitLab CI + +```yaml +security-lint: + image: ghcr.io/your-org/php-security-linter:latest + script: + - security-lint . -s medium -f json -o security-report.json + artifacts: + reports: + sast: security-report.json +``` + +### Docker Details + +#### Manual Docker Usage + +```bash +# Build image +docker build -t php-security-linter:latest . + +# Run (scan current directory) +docker run --rm -v $(pwd):/target:ro php-security-linter:latest /target + +# With options +docker run --rm -v $(pwd):/target:ro php-security-linter:latest \ + /target/app -s high -f json +``` + +#### Environment Variables + +| Variable | Description | +|----------|-------------| +| `PHP_SECURITY_LINT_IMAGE` | Docker image to use (default: php-security-linter:latest) | + +--- + + +## 日本語 + +PHP および Laravel アプリケーション向けの静的セキュリティ解析ツールです。 +再帰的なテイント解析により、関数呼び出しを通じた脆弱性を検出します。 + +### 特徴 + +- **包括的な脆弱性検出**: XSS、SQLインジェクション、コマンドインジェクション、パストラバーサルなど +- **再帰的テイント解析**: ユーザー入力が関数を通じてどのように伝播するかを追跡 +- **Blade テンプレート解析**: Laravel 特有の `{!! !!}` 構文やコンテキスト別XSS検出 +- **スマートなエスケープ検出**: `htmlspecialchars()` 等のエスケープ関数を認識 +- **エスケープ破壊の検出**: `html_entity_decode()` 等によるエスケープ無効化を検出 +- **シンタックスハイライト**: ターミナル出力でコードスニペットを色分け表示 +- **多言語対応**: 日本語/英語のメッセージ出力 + +### インストール + +#### 方法1: Docker(推奨) + +PHPやComposerの環境構築なしで使用できます。 + +```bash +# リポジトリをクローン +git clone https://github.com/your-org/php-laravel-security-linter.git +cd php-laravel-security-linter + +# インストール (Dockerイメージのビルドとコマンドのインストール) +./install.sh +``` + +インストール後は `php-security-lint` コマンドで使用できます: + +```bash +# プロジェクトディレクトリで実行 +cd /path/to/your/laravel-project +php-security-lint . +php-security-lint app/ -s high +``` + +#### 方法2: 直接実行 + +PHP 8.1以上とComposerが必要です。 + +```bash +git clone https://github.com/your-org/php-laravel-security-linter.git +cd php-laravel-security-linter +composer install +php bin/security-lint /path/to/target +``` + +### 使用方法 + +```bash +# 単一ファイルの解析 +php bin/security-lint path/to/file.php + +# ディレクトリの解析 +php bin/security-lint app/ + +# 高重大度のみ表示 +php bin/security-lint app/ -s high + +# コードスニペット表示 (前後3行) +php bin/security-lint app/ -c + +# コードスニペット表示 (前後5行) +php bin/security-lint app/ -c 5 + +# JSON形式で出力 +php bin/security-lint app/ -f json -o report.json + +# 英語で出力 +php bin/security-lint app/ -l en +``` + +### オプション + +#### 出力オプション + +| オプション | 説明 | +|-----------|------| +| `-f, --format` | 出力形式: text, json, html, sarif, markdown | +| `-s, --severity` | 最小重大度: low, medium, high, critical | +| `-o, --output` | ファイルに出力 | +| `-l, --lang` | 言語: ja, en | +| `-c, --context [N]` | 問題行の前後N行のコードスニペットを表示 (デフォルト: 3) | +| `--verbose` | 詳細情報を表示 | +| `-q, --quiet` | 進捗を非表示 | +| `--no-colors` | カラー出力を無効化 | + +#### 除外/包含オプション + +| オプション | 説明 | +|-----------|------| +| `-e, --exclude` | 除外パターン (複数回使用可能) | +| `-i, --include` | 包含パターン (除外を上書き) | +| `--include-vendor` | vendor ディレクトリも解析 | +| `--include-tests` | tests ディレクトリも解析 | +| `--no-default-excludes` | デフォルト除外パターンを使用しない | +| `--show-excluded` | 除外されるパターンを表示 | + +#### 解析オプション + +| オプション | 説明 | +|-----------|------| +| `-d, --recursive-depth` | 再帰解析の深度 (デフォルト: 10) | + +### デフォルト除外パターン + +以下のディレクトリ/パターンはデフォルトで除外されます: + +- `vendor/*` - Composer 依存関係 +- `node_modules/*` - npm 依存関係 +- `storage/*` - Laravel ストレージ +- `bootstrap/cache/*` - Laravel キャッシュ +- `public/vendor/*` - 公開アセット +- `.git/*`, `.svn/*` - バージョン管理 +- `tests/*` - テストファイル +- `cache/*`, `tmp/*`, `temp/*` - 一時ファイル + +これらを含めて解析したい場合は `--include-vendor` や `--include-tests` を使用するか、`--no-default-excludes` で全て無効化できます。 + +### 設定ファイル + +プロジェクトルートに `.security-lint.json` を配置することで設定を永続化できます: + +```json +{ + "severity": "medium", + "format": "text", + "lang": "ja", + "exclude": ["custom/legacy/*"], + "include": ["vendor/my-company/*"], + "includeVendor": false, + "includeTests": false +} +``` + +### 検出可能な脆弱性 + +#### XSS (クロスサイトスクリプティング) + +- Blade の `{!! !!}` 生出力 +- JavaScript コンテキスト内の出力 +- イベントハンドラ属性内の出力 +- URL コンテキスト (javascript: URL) +- Style インジェクション +- テンプレートインジェクション +- エスケープ破壊関数の使用 +- 危険なハードコード HTML + +#### SQLインジェクション + +- `DB::raw()` への汚染データ +- 文字列連結によるクエリ構築 +- PDO/MySQLi の直接クエリ +- Laravel クエリビルダーの Raw メソッド + +#### コマンドインジェクション + +- `exec()`, `shell_exec()`, `system()` 等 +- `eval()`, `create_function()` 等 +- 動的ファイルインクルード +- Symfony Process の不適切な使用 + +#### パストラバーサル + +- ファイル操作関数への汚染パス +- Laravel Storage への汚染パス +- ファイルダウンロード/アップロード + +#### 認証セキュリティ + +- 弱いハッシュアルゴリズム (MD5, SHA1) +- ハードコードされた認証情報 +- タイミング攻撃に脆弱な比較 + +#### CSRF/セッション + +- CSRF トークンの欠落 +- 安全でないセッション設定 +- セッション固定化 + +#### 設定セキュリティ + +- デバッグ出力 (phpinfo, var_dump) +- 安全でない unserialize +- 機密情報のログ出力 + +### 出力例 + +``` +╔════════════════════════════════════════════════════════════╗ +║ PHP/Laravel セキュリティリンター v1.0.0 ║ +╚════════════════════════════════════════════════════════════╝ +解析中: app/ + +HIGH (2) +──────────────────────────────────────────────────────────── + +[XSS] Blade の {!! !!} による生出力はXSSの脆弱性を引き起こす可能性があります。 + 📍 resources/views/user.blade.php:15 + + ┌─ user.blade.php + │ 12 + └─ + 🏷️ CWE-79 | A7:2017-XSS + 💡 自動エスケープされる {{ }} を使用してください。 + +════════════════════════════════════════════════════════════ +サマリー +──────────────────────────────────────────────────────────── + クリティカル: 0 + 高: 2 + 中: 0 + 低: 0 +──────────────────────────────────────────────────────────── + 合計: 2 + +0.15 秒で完了 +``` + +### 終了コード + +| コード | 意味 | +|--------|------| +| 0 | 問題なし | +| 1 | 中/低重大度の問題あり | +| 2 | クリティカル/高重大度の問題あり | + +### CI/CD 統合 + +#### GitHub Actions (Docker) + +```yaml +name: Security Scan + +on: [push, pull_request] + +jobs: + security-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Security Linter + run: | + docker run --rm -v ${{ github.workspace }}:/target \ + ghcr.io/your-org/php-security-linter:latest \ + /target -s high -f sarif -o /target/security.sarif + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: security.sarif +``` + +#### GitLab CI + +```yaml +security-lint: + image: ghcr.io/your-org/php-security-linter:latest + script: + - security-lint . -s medium -f json -o security-report.json + artifacts: + reports: + sast: security-report.json +``` + +### Docker 詳細 + +#### 手動でDockerを使用 + +```bash +# イメージのビルド +docker build -t php-security-linter:latest . + +# 実行 (カレントディレクトリをスキャン) +docker run --rm -v $(pwd):/target:ro php-security-linter:latest /target + +# オプション付き +docker run --rm -v $(pwd):/target:ro php-security-linter:latest \ + /target/app -s high -f json +``` + +#### 環境変数 + +| 変数 | 説明 | +|------|------| +| `PHP_SECURITY_LINT_IMAGE` | 使用するDockerイメージ (デフォルト: php-security-linter:latest) | + +--- + +## Documentation / ドキュメント + +- [Detection Rules / 検出ルール詳細](docs/DETECTION_RULES.md) +- [Quick Reference / クイックリファレンス](docs/QUICK_REFERENCE.md) + +## License / ライセンス + +MIT License + +## References / 参考資料 + +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [CWE/SANS Top 25](https://cwe.mitre.org/top25/) +- [Laravel Security](https://laravel.com/docs/security) diff --git a/bin/security-lint b/bin/security-lint new file mode 100755 index 0000000..93b5f33 --- /dev/null +++ b/bin/security-lint @@ -0,0 +1,1085 @@ +#!/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) { + $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)); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f0493c8 --- /dev/null +++ b/composer.json @@ -0,0 +1,16 @@ +{ + "name": "security-linter/php-laravel", + "description": "Security linter for PHP and Laravel applications", + "type": "project", + "license": "MIT", + "autoload": { + "psr-4": { + "SecurityLinter\\": "src/" + } + }, + "require": { + "php": ">=8.1", + "nikic/php-parser": "^5.0" + }, + "bin": ["bin/security-lint"] +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..39cbc90 --- /dev/null +++ b/composer.lock @@ -0,0 +1,79 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c73859b08e410cd435403c8d54de03ad", + "packages": [ + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ac75663 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' + +services: + security-lint: + build: + context: . + dockerfile: Dockerfile + image: php-security-linter:latest + volumes: + # Mount the target directory as read-only + - ${TARGET_PATH:-.}:/target:ro + # Default command can be overridden + # Example: docker-compose run --rm security-lint /target/app -s high diff --git a/docs/DETECTION_RULES.md b/docs/DETECTION_RULES.md new file mode 100644 index 0000000..5ceee80 --- /dev/null +++ b/docs/DETECTION_RULES.md @@ -0,0 +1,861 @@ +# PHP/Laravel セキュリティリンター 検出ルール一覧 + +このドキュメントでは、セキュリティリンターが検出できる脆弱性パターンを詳細に説明します。 + +--- + +## 目次 + +1. [XSS (クロスサイトスクリプティング)](#1-xss-クロスサイトスクリプティング) +2. [SQLインジェクション](#2-sqlインジェクション) +3. [コマンドインジェクション](#3-コマンドインジェクション) +4. [パストラバーサル](#4-パストラバーサル) +5. [認証セキュリティ](#5-認証セキュリティ) +6. [CSRF/セッションセキュリティ](#6-csrfセッションセキュリティ) +7. [設定セキュリティ](#7-設定セキュリティ) + +--- + +## 1. XSS (クロスサイトスクリプティング) + +### 1.1 Blade テンプレートの生出力 + +| パターン | 重大度 | 説明 | +|---------|--------|------| +| `{!! $var !!}` | HIGH | エスケープされていない変数の出力 | +| `{!! $array['key'] !!}` | HIGH | 配列アクセスの生出力 | +| `{!! $obj->prop !!}` | HIGH | プロパティアクセスの生出力 | + +**安全と判定されるパターン:** +```blade +{{-- エスケープ関数でラップされている場合 --}} +{!! htmlspecialchars($var) !!} +{!! e($var) !!} +{!! htmlentities($var) !!} +{!! strip_tags($html) !!} + +{{-- 文字列リテラルとエスケープ値の連結 --}} +{!! '
' . htmlspecialchars($name) . '
' !!} + +{{-- Laravel 組み込みの安全な出力 --}} +{!! csrf_field() !!} +{!! method_field('PUT') !!} +{!! $errors !!} +{!! $slot !!} + +{{-- Markdown プロセッサ --}} +{!! Markdown::parse($content) !!} +{!! Parsedown::instance()->text($content) !!} + +{{-- サニタイズ関数 --}} +{!! clean($html) !!} +{!! sanitize($html) !!} +{!! purify($html) !!} +``` + +### 1.2 エスケープ破壊関数の検出 + +エスケープ処理を無効化する関数呼び出しを検出します。 + +| 関数 | 説明 | +|------|------| +| `html_entity_decode()` | HTMLエンティティをデコード | +| `htmlspecialchars_decode()` | htmlspecialchars の逆変換 | +| `urldecode()` | URLエンコードをデコード | +| `rawurldecode()` | rawurlencode の逆変換 | +| `base64_decode()` | Base64 デコード | +| `json_decode()` | JSON デコード | +| `stripslashes()` | スラッシュを除去 | +| `stripcslashes()` | C スタイルのスラッシュを除去 | + +**検出例:** +```blade +{{-- 危険: エスケープ後にデコードしている --}} +{!! html_entity_decode(htmlspecialchars($data)) !!} +{!! urldecode(htmlspecialchars($url)) !!} +``` + +### 1.3 危険なハードコード HTML の検出 + +文字列リテラル内の危険な HTML を検出します。 + +| 検出パターン | 説明 | +|-------------|------| +| `' . htmlspecialchars($input); +} + +// 安全: 危険なタグを含まない +function safeFormatter($input) { + return '
' . htmlspecialchars($input) . '
'; +} +``` + +### 1.4 JavaScript コンテキスト + +| パターン | 重大度 | 説明 | +|---------|--------|------| +| `` | MEDIUM | script 内の Blade 出力 | +| `` | CRITICAL | script 内の生出力 | +| `@json($var)` in ` +``` + +### 1.5 URL コンテキスト + +`javascript:` URL によるXSSを検出します。 + +| パターン | 重大度 | +|---------|--------| +| `href="{{ $url }}"` | MEDIUM | +| `src="{{ $url }}"` | MEDIUM | +| `action="{{ $url }}"` | MEDIUM | +| `formaction="{{ $url }}"` | MEDIUM | + +**安全と判定されるパターン:** +```blade +{{-- route() や url() ヘルパーは安全 --}} +Home +About +
+``` + +### 1.6 イベントハンドラ + +| パターン | 重大度 | +|---------|--------| +| `onclick="{{ $var }}"` | HIGH | +| `onerror="{{ $var }}"` | HIGH | +| `onload="{{ $var }}"` | HIGH | +| `onclick="{!! $var !!}"` | CRITICAL | + +**推奨される対策:** +```blade +{{-- 危険 --}} + + +{{-- 推奨: data 属性を使用 --}} + + +``` + +### 1.7 Style インジェクション + +| パターン | 重大度 | +|---------|--------| +| `style="{{ $css }}"` | MEDIUM | +| `style="color: {{ $color }}"` | MEDIUM | + +### 1.8 引用符なし属性 + +| パターン | 重大度 | +|---------|--------| +| `data-value={{ $var }}` | LOW | + +### 1.9 テンプレートインジェクション + +| パターン | 重大度 | +|---------|--------| +| `@include($var)` | HIGH | +| `@extends($var)` | HIGH | +| `@component($var)` | HIGH | +| `@each($var, ...)` | HIGH | + +### 1.10 SVG コンテキスト + +| パターン | 重大度 | +|---------|--------| +| `{{ $var }}` | MEDIUM | + +### 1.11 @php ブロック + +| パターン | 重大度 | +|---------|--------| +| `@php echo $var; @endphp` | MEDIUM | + +### 1.12 PHP の echo/print + +| パターン | 重大度 | +|---------|--------| +| `echo $taintedVar;` | HIGH | +| `print $taintedVar;` | HIGH | +| `printf("%s", $taintedVar)` | HIGH | + +### 1.13 ユーザー定義関数の再帰解析 + +リンターは、ユーザー定義関数を再帰的に解析して、戻り値がエスケープされているかを判定します。 + +```php +// 安全と判定: 戻り値が常にエスケープされている +function safeFormatter($input) { + return htmlspecialchars(trim($input)); +} + +// 危険と判定: エスケープされていない戻り値がある +function unsafeFormatter($input) { + return trim($input); // エスケープなし +} + +// 危険と判定: エスケープを壊している +function breakEscaping($escaped) { + return html_entity_decode($escaped); +} +``` + +--- + +## 2. SQLインジェクション + +### 2.1 Laravel クエリビルダー + +| パターン | 重大度 | +|---------|--------| +| `DB::raw($userInput)` | HIGH | +| `DB::select($query . $userInput)` | HIGH | +| `->whereRaw($userInput)` | HIGH | +| `->selectRaw($userInput)` | HIGH | +| `->orderByRaw($userInput)` | HIGH | +| `->havingRaw($userInput)` | HIGH | +| `->groupByRaw($userInput)` | HIGH | + +**安全なパターン:** +```php +// パラメータバインディングを使用 +DB::select('SELECT * FROM users WHERE id = ?', [$id]); +$query->whereRaw('column = ?', [$value]); +``` + +### 2.2 PDO + +| パターン | 重大度 | +|---------|--------| +| `$pdo->query($sql . $input)` | HIGH | +| `$pdo->exec($sql . $input)` | HIGH | +| `$pdo->prepare($sql . $input)` | HIGH | + +### 2.3 MySQLi + +| パターン | 重大度 | +|---------|--------| +| `$mysqli->query($sql . $input)` | HIGH | +| `mysqli_query($conn, $sql . $input)` | HIGH | + +### 2.4 文字列連結によるクエリ構築 + +| パターン | 重大度 | +|---------|--------| +| `"SELECT * FROM users WHERE id = " . $id` | HIGH | +| `"SELECT * FROM users WHERE id = {$id}"` | HIGH | + +### 2.5 SQLサニタイザー関数の検出 + +リンターは以下のサニタイザー関数を認識し、適切に使用されている場合は安全と判定します。 + +| 関数/メソッド | 説明 | +|--------------|------| +| `intval()`, `floatval()` | 型キャスト(最も安全) | +| `(int)`, `(float)` | 型キャスト演算子 | +| `mysqli_real_escape_string()` | MySQLi エスケープ | +| `PDO::quote()` | PDO エスケープ | +| `pg_escape_string()`, `pg_escape_literal()` | PostgreSQL エスケープ | +| `addslashes()` | 基本的なエスケープ(非推奨) | +| `filter_var()` + FILTER_VALIDATE_INT | 検証フィルター | + +**安全と判定されるパターン:** +```php +// 型キャストによるサニタイズ +$query = "SELECT * FROM users WHERE id = " . intval($id); +$query = "SELECT * FROM users WHERE id = " . (int)$id; + +// エスケープ関数 +$query = "SELECT * FROM users WHERE name = '" . mysqli_real_escape_string($conn, $name) . "'"; +$query = "SELECT * FROM users WHERE name = " . $pdo->quote($name); + +// ユーザー定義サニタイザー関数 +function sanitizeId($input) { + return intval($input); +} +$query = "SELECT * FROM users WHERE id = " . sanitizeId($userId); +``` + +### 2.6 SQLサニタイザー破壊パターン + +サニタイズ後に以下の関数を使用すると、サニタイズが無効化されます。 + +| 関数 | 説明 | +|------|------| +| `stripslashes()` | エスケープを解除 | +| `urldecode()` | URLデコードで特殊文字を復活 | +| `html_entity_decode()` | HTMLエンティティをデコード | +| `base64_decode()` | Base64 デコード | +| `sprintf()` | フォーマット文字列で迂回可能 | + +**危険なパターン:** +```php +// 危険: サニタイズ後にデコード +$safe = mysqli_real_escape_string($conn, $input); +$unsafe = stripslashes($safe); // エスケープを解除 + +// 危険: urldecode でエスケープを迂回 +$safe = addslashes($input); +$unsafe = urldecode($safe); // %27 -> ' に変換 +``` + +### 2.7 ユーザー定義関数の再帰解析 + +リンターはユーザー定義関数を再帰的に解析し、安全かどうかを判定します。 + +```php +// 安全と判定: 戻り値が常にサニタイズされている +function getIntParam($key) { + return intval($_GET[$key] ?? 0); +} + +// 危険と判定: サニタイズを破壊している +function processInput($input) { + $escaped = mysqli_real_escape_string($this->conn, $input); + return stripslashes($escaped); // エスケープを解除! +} +``` + +--- + +## 3. コマンドインジェクション + +### 3.1 シェル実行関数 + +| 関数 | 重大度 | +|------|--------| +| `exec($cmd . $input)` | CRITICAL | +| `shell_exec($cmd . $input)` | CRITICAL | +| `system($cmd . $input)` | CRITICAL | +| `passthru($cmd . $input)` | CRITICAL | +| `proc_open($cmd . $input, ...)` | CRITICAL | +| `popen($cmd . $input, ...)` | CRITICAL | +| `` `$cmd $input` `` (バッククォート) | CRITICAL | + +### 3.2 コード実行関数 + +| 関数 | 重大度 | +|------|--------| +| `eval($code)` | CRITICAL | +| `create_function($args, $code)` | CRITICAL | +| `assert($expr)` (文字列引数) | CRITICAL | +| `preg_replace('/.../e', ...)` | CRITICAL | + +### 3.3 コールバック関数 + +| 関数 | 重大度 | +|------|--------| +| `call_user_func($userCallback)` | HIGH | +| `call_user_func_array($userCallback, ...)` | HIGH | +| `array_map($userCallback, ...)` | HIGH | +| `array_filter($arr, $userCallback)` | HIGH | + +### 3.4 ファイルインクルード + +| 関数 | 重大度 | +|------|--------| +| `include($userPath)` | CRITICAL | +| `include_once($userPath)` | CRITICAL | +| `require($userPath)` | CRITICAL | +| `require_once($userPath)` | CRITICAL | + +### 3.5 Symfony Process + +| パターン | 重大度 | +|---------|--------| +| `Process::fromShellCommandline($cmd . $input)` | HIGH | +| `new Process($stringCmd . $input)` | HIGH | + +**安全なパターン:** +```php +// 配列引数を使用 +$process = new Process(['command', $arg1, $arg2]); +``` + +### 3.6 Laravel Artisan + +| パターン | 重大度 | +|---------|--------| +| `Artisan::call($userCommand)` | HIGH | + +### 3.7 コマンドサニタイザー関数の検出 + +リンターは以下のサニタイザー関数を認識し、適切に使用されている場合は安全と判定します。 + +| 関数 | 説明 | +|------|------| +| `escapeshellarg()` | 単一の引数をエスケープ | +| `escapeshellcmd()` | コマンド全体をエスケープ | +| `basename()` | ファイル名部分のみ抽出 | +| `intval()`, `floatval()` | 型キャスト | + +**安全と判定されるパターン:** +```php +// escapeshellarg で引数をエスケープ +exec('ls -la ' . escapeshellarg($path)); + +// escapeshellcmd でコマンド全体をエスケープ +exec(escapeshellcmd($command) . ' ' . escapeshellarg($arg)); + +// 型キャストで数値に限定 +exec('kill ' . intval($pid)); + +// 配列引数による安全な Process 使用 +$process = new Process(['convert', $inputFile, $outputFile]); +``` + +### 3.8 コマンドサニタイザー破壊パターン + +サニタイズ後に以下の関数を使用すると、サニタイズが無効化されます。 + +| 関数 | 説明 | +|------|------| +| `stripslashes()` | エスケープを解除 | +| `urldecode()` | URLデコードで特殊文字を復活 | +| `str_replace()` | エスケープ文字を削除可能 | +| `preg_replace()` | エスケープ文字を削除可能 | +| `sprintf()` | フォーマット文字列で迂回可能 | + +**危険なパターン:** +```php +// 危険: サニタイズ後にデコード +$safe = escapeshellarg($input); +$unsafe = urldecode($safe); // %20 -> スペースに変換 + +// 危険: サニタイズを壊す置換 +$safe = escapeshellarg($input); +$unsafe = str_replace("'", "", $safe); // エスケープを除去 + +// 危険: sprintf でフォーマット文字列攻撃 +$cmd = sprintf($userFormat, escapeshellarg($arg)); // $userFormat が制御可能 +``` + +### 3.9 ユーザー定義関数の再帰解析 + +リンターはユーザー定義関数を再帰的に解析し、安全かどうかを判定します。 + +```php +// 安全と判定: 戻り値が常にサニタイズされている +function safeCommand($input) { + return escapeshellarg(trim($input)); +} + +// 危険と判定: サニタイズを破壊している +function processCommand($input) { + $escaped = escapeshellarg($input); + return urldecode($escaped); // エスケープを解除! +} +``` + +--- + +## 4. パストラバーサル + +### 4.1 ファイル操作関数 + +| 関数 | 重大度 | +|------|--------| +| `file_get_contents($userPath)` | HIGH | +| `file_put_contents($userPath, ...)` | HIGH | +| `fopen($userPath, ...)` | HIGH | +| `readfile($userPath)` | HIGH | +| `unlink($userPath)` | HIGH | +| `copy($userPath, ...)` | HIGH | +| `rename($userPath, ...)` | HIGH | + +### 4.2 ファイルアップロード + +| パターン | 重大度 | +|---------|--------| +| `move_uploaded_file($tmp, $userDest)` | HIGH | + +### 4.3 Laravel Storage + +| パターン | 重大度 | +|---------|--------| +| `Storage::get($userPath)` | MEDIUM | +| `Storage::put($userPath, ...)` | MEDIUM | +| `Storage::delete($userPath)` | MEDIUM | +| `Storage::download($userPath)` | MEDIUM | + +### 4.4 レスポンスダウンロード + +| パターン | 重大度 | +|---------|--------| +| `response()->download($userPath)` | HIGH | +| `response()->file($userPath)` | HIGH | + +### 4.5 パスサニタイザー関数の検出 + +リンターは以下のサニタイザー関数を認識し、適切に使用されている場合は安全と判定します。 + +| 関数 | 説明 | +|------|------| +| `basename()` | ディレクトリ成分を除去(最も効果的) | +| `realpath()` | パスを正規化し、存在を確認 | +| `pathinfo(..., PATHINFO_BASENAME)` | ファイル名部分のみ抽出 | +| `intval()` | 数値IDに限定 | +| `Str::random()`, `Str::uuid()` | 安全なファイル名生成 | +| `$file->hashName()` | ハッシュベースの安全なファイル名 | + +**安全と判定されるパターン:** +```php +// basename でディレクトリを除去 +$filename = basename($userInput); +file_get_contents($basePath . '/' . $filename); + +// realpath で検証 +$path = realpath($basePath . '/' . $userInput); +if ($path && str_starts_with($path, $basePath)) { + file_get_contents($path); +} + +// 数値IDに限定 +$content = file_get_contents('/docs/' . intval($id) . '.txt'); + +// 安全なファイル名生成 +$filename = Str::random(40) . '.pdf'; +Storage::put($filename, $content); + +// ハッシュベースのファイル名 +$path = $request->file('upload')->hashName(); +``` + +### 4.6 危険なパストラバーサルパターン + +以下のパターンが文字列リテラルに含まれている場合、警告します。 + +| パターン | 説明 | +|---------|------| +| `..` | ディレクトリトラバーサル | +| `../`, `..\\` | Unix/Windows トラバーサル | +| `%2e%2e` | URLエンコードされた `..` | +| `%252e%252e` | ダブルURLエンコード | +| `..%c0%af`, `..%c1%9c` | オーバーロング UTF-8 エンコード | + +### 4.7 パスサニタイザー破壊パターン + +サニタイズ後に以下の関数を使用すると、サニタイズが無効化されます。 + +| 関数 | 説明 | +|------|------| +| `urldecode()` | `%2e%2e` → `..` | +| `rawurldecode()` | URLデコード | +| `base64_decode()` | Base64 でトラバーサルを隠蔽可能 | +| `hex2bin()` | 16進数でトラバーサルを隠蔽可能 | +| `html_entity_decode()` | HTMLエンティティをデコード | +| `chr()` | 文字を構築可能 | + +**危険なパターン:** +```php +// 危険: サニタイズ後にデコード +$safe = basename($userInput); +$unsafe = urldecode($safe); // %2e%2e -> .. に変換 + +// 危険: base64 でトラバーサルを隠蔽 +$path = base64_decode($userInput); // Li4vLi4vZXRjL3Bhc3N3ZA== -> ../../etc/passwd + +// 危険: サニタイズ前に getClientOriginalName を使用 +$filename = $file->getClientOriginalName(); // ユーザー制御! +Storage::put($filename, $content); // トラバーサル可能 +``` + +### 4.8 ユーザー定義関数の再帰解析 + +リンターはユーザー定義関数を再帰的に解析し、安全かどうかを判定します。 + +```php +// 安全と判定: 戻り値が常にサニタイズされている +function safePath($input) { + return basename(trim($input)); +} + +// 危険と判定: サニタイズを破壊している +function processPath($input) { + $safe = basename($input); + return urldecode($safe); // サニタイズを解除! +} + +// 安全と判定: 検証ロジックを含む +function validatePath($input, $baseDir) { + $path = realpath($baseDir . '/' . $input); + if ($path && str_starts_with($path, $baseDir)) { + return $path; + } + return null; +} +``` + +--- + +## 5. 認証セキュリティ + +### 5.1 弱いハッシュアルゴリズム + +| 関数 | 重大度 | +|------|--------| +| `md5($password)` | HIGH | +| `sha1($password)` | HIGH | +| `sha256($password)` | MEDIUM | +| `hash('md5', $password)` | HIGH | + +### 5.2 不適切なパスワードハッシュ + +| パターン | 重大度 | +|---------|--------| +| `password_hash($pw, PASSWORD_MD5)` | HIGH | +| `password_hash($pw, ..., ['cost' => 4])` | MEDIUM | + +### 5.3 ハードコードされた認証情報 + +| パターン | 重大度 | +|---------|--------| +| `$password = 'secret123'` | HIGH | +| `$apiKey = 'sk-xxx...'` | HIGH | +| `['password' => 'hardcoded']` | HIGH | + +### 5.4 タイミング攻撃 + +| パターン | 重大度 | +|---------|--------| +| `$token == $userToken` | MEDIUM | +| `strcmp($secret, $input)` | MEDIUM | + +**推奨:** +```php +// 定時間比較を使用 +hash_equals($expected, $actual); +password_verify($password, $hash); +``` + +### 5.5 Base64 エンコード + +| パターン | 重大度 | +|---------|--------| +| `base64_encode($password)` | HIGH | + +--- + +## 6. CSRF/セッションセキュリティ + +### 6.1 CSRF トークン + +| パターン | 重大度 | +|---------|--------| +| `` without `@csrf` | HIGH | +| Form missing `csrf_field()` | HIGH | + +### 6.2 メソッドスプーフィング + +| パターン | 重大度 | +|---------|--------| +| PUT/PATCH/DELETE form without `@method` | LOW | + +### 6.3 セッション設定 + +| パターン | 重大度 | +|---------|--------| +| `session_start()` without options | MEDIUM | +| Missing `cookie_httponly` | MEDIUM | +| Missing `cookie_secure` | MEDIUM | +| Missing `cookie_samesite` | MEDIUM | + +### 6.4 セッション固定化 + +| パターン | 重大度 | +|---------|--------| +| `session_regenerate_id()` without `true` | MEDIUM | +| `session_regenerate_id(false)` | MEDIUM | + +### 6.5 Cookie セキュリティ + +| パターン | 重大度 | +|---------|--------| +| Cookie without `httponly` | MEDIUM | +| Cookie without `secure` | MEDIUM | +| Cookie without `samesite` | MEDIUM | + +--- + +## 7. 設定セキュリティ + +### 7.1 デバッグ出力 + +| 関数 | 重大度 | +|------|--------| +| `phpinfo()` | HIGH | +| `var_dump($var)` | MEDIUM | +| `print_r($var)` | MEDIUM | +| `dd($var)` | MEDIUM | +| `dump($var)` | MEDIUM | + +### 7.2 エラー表示 + +| パターン | 重大度 | +|---------|--------| +| `error_reporting(-1)` | MEDIUM | +| `ini_set('display_errors', '1')` | MEDIUM | +| `ini_set('display_startup_errors', '1')` | MEDIUM | + +### 7.3 安全でないデシリアライズ + +| パターン | 重大度 | +|---------|--------| +| `unserialize($data)` | HIGH | +| `unserialize($data, ['allowed_classes' => true])` | HIGH | + +**安全なパターン:** +```php +unserialize($data, ['allowed_classes' => false]); +unserialize($data, ['allowed_classes' => [AllowedClass::class]]); +``` + +### 7.4 機密情報のログ出力 + +| パターン | 重大度 | +|---------|--------| +| `Log::info($password)` | MEDIUM | +| `logger($apiKey)` | MEDIUM | + +### 7.5 ハードコードされた設定 + +| パターン | 重大度 | +|---------|--------| +| `'debug' => true` | MEDIUM | +| `'key' => 'hardcoded-secret'` | HIGH | + +--- + +## 再帰的なテイント解析 + +リンターは、関数呼び出しを通じてユーザー入力(テイントデータ)がどのように伝播するかを追跡します。 + +### テイントソース(汚染源) + +```php +$_GET, $_POST, $_REQUEST, $_COOKIE, $_FILES, $_SERVER + +$request->input() +$request->get() +$request->post() +$request->query() +$request->all() +$request->only() +$request->except() +$request->file() +$request->cookie() + +request()->input() +Request::input() + +file_get_contents('php://input') +``` + +### サニタイザー(浄化関数) + +以下の関数を通過したデータは、対応する脆弱性に対して安全と判定されます: + +```php +// XSS +htmlspecialchars(), htmlentities(), strip_tags(), e() + +// SQL +addslashes(), mysqli_real_escape_string(), pg_escape_string() + +// パス +basename(), realpath() + +// 型キャスト +(int), (float), (bool), intval(), floatval(), boolval() + +// バリデーション +validate(), validated() +``` + +### 解析の深度 + +デフォルトで最大10レベルの関数呼び出しを追跡します。 + +```php +// 例: 3レベルの追跡 +function level1($input) { return level2($input); } +function level2($input) { return level3($input); } +function level3($input) { return $input; } // テイント + +echo level1($_GET['input']); // 検出される +``` + +--- + +## 使用例 + +```bash +# 単一ファイルの解析 +php bin/security-lint path/to/file.php + +# ディレクトリの解析(再帰的な関数解析が有効) +php bin/security-lint app/ + +# 高重大度のみ表示 +php bin/security-lint app/ -s high + +# JSON 形式で出力 +php bin/security-lint app/ -f json -o report.json + +# 英語で出力 +php bin/security-lint app/ -l en +``` + +--- + +## 重大度レベル + +| レベル | 説明 | +|--------|------| +| **CRITICAL** | 即時対応が必要。直接的な攻撃が可能 | +| **HIGH** | 早急な対応が必要。重大な脆弱性 | +| **MEDIUM** | 計画的な対応が必要。潜在的なリスク | +| **LOW** | 改善推奨。ベストプラクティス違反 | + +--- + +## 制限事項 + +1. **静的解析の限界**: 実行時の値は判定できません +2. **動的なメソッド呼び出し**: `$obj->$method()` は追跡困難 +3. **外部ライブラリ**: vendor 内のコードは解析対象外 +4. **フレームワーク固有の機能**: 一部のLaravel固有パターンは検出できない場合があります +5. **偽陽性**: 安全なコードが危険と判定される場合があります + +--- + +## バージョン + +- ドキュメント作成日: 2024 +- 対応 PHP バージョン: 8.0+ +- 対応 Laravel バージョン: 9.x, 10.x, 11.x diff --git a/docs/QUICK_REFERENCE.md b/docs/QUICK_REFERENCE.md new file mode 100644 index 0000000..f764e88 --- /dev/null +++ b/docs/QUICK_REFERENCE.md @@ -0,0 +1,290 @@ +# セキュリティリンター クイックリファレンス + +## XSS 検出パターン + +### Blade テンプレート + +``` +重大度: CRITICAL +├── # JS内の生出力 +└── onclick="{!! $var !!}" # イベントハンドラ内の生出力 + +重大度: HIGH +├── {!! $var !!} # 生出力 +├── {!! func($var) !!} # 関数がエスケープしない場合 +├── {!! html_entity_decode(...) !!} # エスケープ破壊 +├── {!! ' # JS コンテキスト +├── href="{{ $var }}" # URL コンテキスト +├── style="{{ $var }}" # CSS インジェクション +└── {{ $var }} # SVG コンテキスト + +重大度: LOW +├── data-x={{ $var }} # 引用符なし属性 +└── @json($var) in