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 <noreply@anthropic.com>
This commit is contained in:
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -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
|
||||
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
@@ -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/
|
||||
23
.security-lint.json.example
Normal file
23
.security-lint.json.example
Normal file
@@ -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
|
||||
}
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -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"]
|
||||
618
README.md
Normal file
618
README.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# PHP/Laravel Security Linter
|
||||
|
||||
**[English](#english) | [日本語](#japanese)**
|
||||
|
||||
---
|
||||
|
||||
<a name="english"></a>
|
||||
## 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 <div class="user-info">
|
||||
│ 13 <h2>{{ $user->name }}</h2>
|
||||
│ 14 <div class="bio">
|
||||
│ 15 ▶ {!! $userName !!}
|
||||
│ 16 </div>
|
||||
│ 17 </div>
|
||||
└─
|
||||
🏷️ 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) |
|
||||
|
||||
---
|
||||
|
||||
<a name="japanese"></a>
|
||||
## 日本語
|
||||
|
||||
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 <div class="user-info">
|
||||
│ 13 <h2>{{ $user->name }}</h2>
|
||||
│ 14 <div class="bio">
|
||||
│ 15 ▶ {!! $userName !!}
|
||||
│ 16 </div>
|
||||
│ 17 </div>
|
||||
└─
|
||||
🏷️ 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)
|
||||
1085
bin/security-lint
Executable file
1085
bin/security-lint
Executable file
File diff suppressed because it is too large
Load Diff
16
composer.json
Normal file
16
composer.json
Normal file
@@ -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"]
|
||||
}
|
||||
79
composer.lock
generated
Normal file
79
composer.lock
generated
Normal file
@@ -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"
|
||||
}
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -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
|
||||
861
docs/DETECTION_RULES.md
Normal file
861
docs/DETECTION_RULES.md
Normal file
@@ -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) !!}
|
||||
|
||||
{{-- 文字列リテラルとエスケープ値の連結 --}}
|
||||
{!! '<div>' . htmlspecialchars($name) . '</div>' !!}
|
||||
|
||||
{{-- 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 を検出します。
|
||||
|
||||
| 検出パターン | 説明 |
|
||||
|-------------|------|
|
||||
| `<script>` | スクリプトタグ |
|
||||
| `<iframe>` | iframe タグ |
|
||||
| `<object>`, `<embed>` | プラグインタグ |
|
||||
| `<meta>`, `<link>`, `<base>` | メタ情報タグ |
|
||||
| `<form>` | フォームタグ(フィッシング) |
|
||||
| `onclick=`, `onerror=` 等 | イベントハンドラ |
|
||||
| `javascript:` | JavaScript プロトコル |
|
||||
| `vbscript:` | VBScript プロトコル |
|
||||
| `data:...;base64` | Data URL |
|
||||
|
||||
**検出例:**
|
||||
```php
|
||||
// 危険: ハードコードされた script タグ
|
||||
function badFormatter($input) {
|
||||
return '<script>alert("XSS")</script>' . htmlspecialchars($input);
|
||||
}
|
||||
|
||||
// 安全: 危険なタグを含まない
|
||||
function safeFormatter($input) {
|
||||
return '<div class="wrapper">' . htmlspecialchars($input) . '</div>';
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 JavaScript コンテキスト
|
||||
|
||||
| パターン | 重大度 | 説明 |
|
||||
|---------|--------|------|
|
||||
| `<script>{{ $var }}</script>` | MEDIUM | script 内の Blade 出力 |
|
||||
| `<script>{!! $var !!}</script>` | CRITICAL | script 内の生出力 |
|
||||
| `@json($var)` in `<script>` | LOW | JSON ディレクティブの使用 |
|
||||
|
||||
**推奨される対策:**
|
||||
```blade
|
||||
{{-- 推奨: Js::from() を使用 --}}
|
||||
<script>
|
||||
var data = {{ Js::from($data) }};
|
||||
</script>
|
||||
```
|
||||
|
||||
### 1.5 URL コンテキスト
|
||||
|
||||
`javascript:` URL によるXSSを検出します。
|
||||
|
||||
| パターン | 重大度 |
|
||||
|---------|--------|
|
||||
| `href="{{ $url }}"` | MEDIUM |
|
||||
| `src="{{ $url }}"` | MEDIUM |
|
||||
| `action="{{ $url }}"` | MEDIUM |
|
||||
| `formaction="{{ $url }}"` | MEDIUM |
|
||||
|
||||
**安全と判定されるパターン:**
|
||||
```blade
|
||||
{{-- route() や url() ヘルパーは安全 --}}
|
||||
<a href="{{ route('home') }}">Home</a>
|
||||
<a href="{{ url('/about') }}">About</a>
|
||||
<form action="{{ action('Controller@method') }}">
|
||||
```
|
||||
|
||||
### 1.6 イベントハンドラ
|
||||
|
||||
| パターン | 重大度 |
|
||||
|---------|--------|
|
||||
| `onclick="{{ $var }}"` | HIGH |
|
||||
| `onerror="{{ $var }}"` | HIGH |
|
||||
| `onload="{{ $var }}"` | HIGH |
|
||||
| `onclick="{!! $var !!}"` | CRITICAL |
|
||||
|
||||
**推奨される対策:**
|
||||
```blade
|
||||
{{-- 危険 --}}
|
||||
<button onclick="doAction('{{ $action }}')">Click</button>
|
||||
|
||||
{{-- 推奨: data 属性を使用 --}}
|
||||
<button data-action="{{ $action }}" class="js-action">Click</button>
|
||||
<script>
|
||||
document.querySelector('.js-action').addEventListener('click', function() {
|
||||
doAction(this.dataset.action);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 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 コンテキスト
|
||||
|
||||
| パターン | 重大度 |
|
||||
|---------|--------|
|
||||
| `<svg>{{ $var }}</svg>` | 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 トークン
|
||||
|
||||
| パターン | 重大度 |
|
||||
|---------|--------|
|
||||
| `<form method="POST">` 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
|
||||
290
docs/QUICK_REFERENCE.md
Normal file
290
docs/QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# セキュリティリンター クイックリファレンス
|
||||
|
||||
## XSS 検出パターン
|
||||
|
||||
### Blade テンプレート
|
||||
|
||||
```
|
||||
重大度: CRITICAL
|
||||
├── <script>{!! $var !!}</script> # JS内の生出力
|
||||
└── onclick="{!! $var !!}" # イベントハンドラ内の生出力
|
||||
|
||||
重大度: HIGH
|
||||
├── {!! $var !!} # 生出力
|
||||
├── {!! func($var) !!} # 関数がエスケープしない場合
|
||||
├── {!! html_entity_decode(...) !!} # エスケープ破壊
|
||||
├── {!! '<script>...' . $var !!} # 危険なハードコードHTML
|
||||
├── onclick="{{ $var }}" # イベントハンドラ
|
||||
└── @include($var) # テンプレートインジェクション
|
||||
|
||||
重大度: MEDIUM
|
||||
├── <script>{{ $var }}</script> # JS コンテキスト
|
||||
├── href="{{ $var }}" # URL コンテキスト
|
||||
├── style="{{ $var }}" # CSS インジェクション
|
||||
└── <svg>{{ $var }}</svg> # SVG コンテキスト
|
||||
|
||||
重大度: LOW
|
||||
├── data-x={{ $var }} # 引用符なし属性
|
||||
└── @json($var) in <script> # JSON ディレクティブ
|
||||
```
|
||||
|
||||
### 安全なパターン(検出されない)
|
||||
|
||||
```blade
|
||||
{!! htmlspecialchars($var) !!} ✓ エスケープ関数
|
||||
{!! e($var) !!} ✓ Laravel ヘルパー
|
||||
{!! '<div>' . e($var) . '</div>' !!} ✓ 安全な連結
|
||||
{!! csrf_field() !!} ✓ 組み込み関数
|
||||
{!! $errors !!} ✓ Laravel 組み込み
|
||||
{!! Markdown::parse($var) !!} ✓ Markdown プロセッサ
|
||||
{!! safeFunc($var) !!} ✓ エスケープを返す関数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SQLインジェクション 検出パターン
|
||||
|
||||
```
|
||||
重大度: HIGH
|
||||
├── DB::raw("... $var ...")
|
||||
├── DB::select("... $var ...")
|
||||
├── ->whereRaw("... $var ...")
|
||||
├── ->orderByRaw($var)
|
||||
├── $pdo->query("... $var ...")
|
||||
├── $mysqli->query("... $var ...")
|
||||
└── "SELECT * FROM t WHERE id = $var"
|
||||
```
|
||||
|
||||
### SQLサニタイザー関数(これらは安全と判定)
|
||||
|
||||
```php
|
||||
intval($var), (int)$var ✓ 型キャスト
|
||||
floatval($var), (float)$var ✓ 型キャスト
|
||||
mysqli_real_escape_string($conn, $var) ✓ エスケープ
|
||||
$pdo->quote($var) ✓ PDO エスケープ
|
||||
filter_var($var, FILTER_VALIDATE_INT) ✓ 検証
|
||||
```
|
||||
|
||||
### SQLサニタイザー破壊パターン(危険)
|
||||
|
||||
```php
|
||||
stripslashes(mysqli_real_escape_string(...)) ✗
|
||||
urldecode(addslashes($var)) ✗
|
||||
html_entity_decode($pdo->quote($var)) ✗
|
||||
```
|
||||
|
||||
### 安全なパターン
|
||||
|
||||
```php
|
||||
DB::select('SELECT * FROM t WHERE id = ?', [$id]); ✓
|
||||
$query->where('column', '=', $value); ✓
|
||||
$query->whereRaw('col = ?', [$value]); ✓
|
||||
"SELECT * FROM t WHERE id = " . intval($id) ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## コマンドインジェクション 検出パターン
|
||||
|
||||
```
|
||||
重大度: CRITICAL
|
||||
├── exec($cmd . $input)
|
||||
├── shell_exec($cmd . $input)
|
||||
├── system($cmd . $input)
|
||||
├── `$cmd $input`
|
||||
├── eval($code)
|
||||
├── include($userPath)
|
||||
└── preg_replace('/.../e', ...)
|
||||
|
||||
重大度: HIGH
|
||||
├── call_user_func($userCallback)
|
||||
├── Process::fromShellCommandline($cmd)
|
||||
└── Artisan::call($userCmd)
|
||||
```
|
||||
|
||||
### コマンドサニタイザー関数(これらは安全と判定)
|
||||
|
||||
```php
|
||||
escapeshellarg($arg) ✓ 引数エスケープ
|
||||
escapeshellcmd($cmd) ✓ コマンドエスケープ
|
||||
basename($path) ✓ ファイル名のみ
|
||||
intval($var) ✓ 型キャスト
|
||||
```
|
||||
|
||||
### コマンドサニタイザー破壊パターン(危険)
|
||||
|
||||
```php
|
||||
urldecode(escapeshellarg($var)) ✗
|
||||
str_replace("'", "", escapeshellarg($var)) ✗
|
||||
stripslashes(escapeshellcmd($var)) ✗
|
||||
```
|
||||
|
||||
### 安全なパターン
|
||||
|
||||
```php
|
||||
$process = new Process(['cmd', $arg1, $arg2]); ✓
|
||||
exec(escapeshellcmd($cmd) . ' ' . escapeshellarg($a)); ✓
|
||||
exec('ls -la ' . escapeshellarg($path)); ✓
|
||||
exec('kill ' . intval($pid)); ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## パストラバーサル 検出パターン
|
||||
|
||||
```
|
||||
重大度: HIGH
|
||||
├── file_get_contents($userPath)
|
||||
├── fopen($userPath, 'r')
|
||||
├── unlink($userPath)
|
||||
├── move_uploaded_file($tmp, $userDest)
|
||||
└── response()->download($userPath)
|
||||
|
||||
重大度: MEDIUM
|
||||
├── Storage::get($userPath)
|
||||
└── Storage::put($userPath, $content)
|
||||
```
|
||||
|
||||
### パスサニタイザー関数(これらは安全と判定)
|
||||
|
||||
```php
|
||||
basename($path) ✓ ディレクトリ除去
|
||||
realpath($path) ✓ パス正規化
|
||||
pathinfo($path, PATHINFO_BASENAME) ✓ ファイル名抽出
|
||||
intval($id) ✓ 数値ID
|
||||
Str::random(40), Str::uuid() ✓ 安全なファイル名
|
||||
$file->hashName() ✓ ハッシュファイル名
|
||||
```
|
||||
|
||||
### 危険なパストラバーサルパターン
|
||||
|
||||
```
|
||||
.. ✗ ディレクトリトラバーサル
|
||||
../ ..\\ ✗ Unix/Windows
|
||||
%2e%2e ✗ URLエンコード
|
||||
%252e%252e ✗ ダブルエンコード
|
||||
```
|
||||
|
||||
### パスサニタイザー破壊パターン(危険)
|
||||
|
||||
```php
|
||||
urldecode(basename($var)) ✗ %2e%2e -> ..
|
||||
base64_decode($var) ✗ トラバーサル隠蔽
|
||||
$file->getClientOriginalName() ✗ ユーザー制御
|
||||
```
|
||||
|
||||
### 安全なパターン
|
||||
|
||||
```php
|
||||
$safePath = basename($userInput); ✓
|
||||
$realPath = realpath($path); ✓
|
||||
if (str_starts_with($realPath, $allowedDir)) { ... } ✓
|
||||
'/docs/' . intval($id) . '.txt' ✓
|
||||
$request->file('doc')->hashName() ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 認証 検出パターン
|
||||
|
||||
```
|
||||
重大度: HIGH
|
||||
├── md5($password)
|
||||
├── sha1($password)
|
||||
├── $password = 'hardcoded'
|
||||
└── base64_encode($password)
|
||||
|
||||
重大度: MEDIUM
|
||||
├── $token == $userToken # タイミング攻撃
|
||||
└── password_hash($pw, ..., ['cost' => 4])
|
||||
```
|
||||
|
||||
### 安全なパターン
|
||||
|
||||
```php
|
||||
password_hash($pw, PASSWORD_ARGON2ID); ✓
|
||||
password_verify($input, $hash); ✓
|
||||
hash_equals($expected, $actual); ✓
|
||||
Hash::make($password); ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSRF/セッション 検出パターン
|
||||
|
||||
```
|
||||
重大度: HIGH
|
||||
└── <form method="POST"> without @csrf
|
||||
|
||||
重大度: MEDIUM
|
||||
├── session_start() without secure options
|
||||
├── Cookie without httponly
|
||||
└── session_regenerate_id() without true
|
||||
```
|
||||
|
||||
### 安全なパターン
|
||||
|
||||
```blade
|
||||
<form method="POST">
|
||||
@csrf ✓
|
||||
@method('PUT') ✓
|
||||
</form>
|
||||
```
|
||||
|
||||
```php
|
||||
session_start([
|
||||
'cookie_httponly' => true, ✓
|
||||
'cookie_secure' => true, ✓
|
||||
'cookie_samesite' => 'Strict', ✓
|
||||
]);
|
||||
session_regenerate_id(true); ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 設定 検出パターン
|
||||
|
||||
```
|
||||
重大度: HIGH
|
||||
├── phpinfo()
|
||||
└── unserialize($data) without allowed_classes
|
||||
|
||||
重大度: MEDIUM
|
||||
├── dd($var), dump($var)
|
||||
├── var_dump($var), print_r($var)
|
||||
├── error_reporting(-1)
|
||||
├── ini_set('display_errors', '1')
|
||||
└── Log::info($password)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI 使用方法
|
||||
|
||||
```bash
|
||||
# 基本
|
||||
php bin/security-lint <path>
|
||||
|
||||
# オプション
|
||||
-f, --format 出力形式 (text|json|html|sarif|markdown)
|
||||
-s, --severity 最小重大度 (low|medium|high|critical)
|
||||
-o, --output ファイルに出力
|
||||
-l, --lang 言語 (ja|en)
|
||||
-e, --exclude 除外パターン
|
||||
-d, --recursive-depth 再帰深度 (default: 10)
|
||||
--verbose 詳細表示
|
||||
|
||||
# 例
|
||||
php bin/security-lint app/ -s high -f json -o report.json
|
||||
php bin/security-lint resources/views/ -l en
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 終了コード
|
||||
|
||||
| コード | 意味 |
|
||||
|--------|------|
|
||||
| 0 | 問題なし |
|
||||
| 1 | 中/低重大度の問題あり |
|
||||
| 2 | クリティカル/高重大度の問題あり |
|
||||
82
install.sh
Executable file
82
install.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# PHP/Laravel Security Linter - Installation Script
|
||||
#
|
||||
# This script builds the Docker image and installs the wrapper script.
|
||||
#
|
||||
# Usage:
|
||||
# ./install.sh # Default: 1024M memory
|
||||
# PHP_MEMORY_LIMIT=2048M ./install.sh # Custom memory limit
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
IMAGE_NAME="php-security-linter:latest"
|
||||
INSTALL_PATH="/usr/local/bin/php-security-lint"
|
||||
PHP_MEMORY_LIMIT="${PHP_MEMORY_LIMIT:-1024M}"
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ PHP/Laravel Security Linter - Installer ║"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
# Check Docker
|
||||
echo -e "${YELLOW}[1/3] Checking Docker...${NC}"
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker is not installed.${NC}"
|
||||
echo "Please install Docker first: https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker daemon is not running.${NC}"
|
||||
echo "Please start Docker and try again."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Docker is available${NC}"
|
||||
|
||||
# Build Docker image
|
||||
echo ""
|
||||
echo -e "${YELLOW}[2/3] Building Docker image (memory_limit=${PHP_MEMORY_LIMIT})...${NC}"
|
||||
cd "$SCRIPT_DIR"
|
||||
docker build --build-arg PHP_MEMORY_LIMIT="$PHP_MEMORY_LIMIT" -t "$IMAGE_NAME" .
|
||||
echo -e "${GREEN}✓ Docker image built: $IMAGE_NAME${NC}"
|
||||
|
||||
# Install wrapper script
|
||||
echo ""
|
||||
echo -e "${YELLOW}[3/3] Installing wrapper script...${NC}"
|
||||
|
||||
if [[ -w "$(dirname "$INSTALL_PATH")" ]]; then
|
||||
cp "$SCRIPT_DIR/php-security-lint" "$INSTALL_PATH"
|
||||
chmod +x "$INSTALL_PATH"
|
||||
echo -e "${GREEN}✓ Installed to $INSTALL_PATH${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Need sudo to install to $INSTALL_PATH${NC}"
|
||||
sudo cp "$SCRIPT_DIR/php-security-lint" "$INSTALL_PATH"
|
||||
sudo chmod +x "$INSTALL_PATH"
|
||||
echo -e "${GREEN}✓ Installed to $INSTALL_PATH${NC}"
|
||||
fi
|
||||
|
||||
# Done
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN}Installation complete!${NC}"
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " php-security-lint . # Scan current directory"
|
||||
echo " php-security-lint app/ # Scan specific directory"
|
||||
echo " php-security-lint -s high . # High severity only"
|
||||
echo " php-security-lint -f json -o report.json ."
|
||||
echo ""
|
||||
echo "For more options:"
|
||||
echo " php-security-lint --help"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
|
||||
242
php-security-lint
Executable file
242
php-security-lint
Executable file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# php-security-lint - PHP/Laravel Security Linter
|
||||
#
|
||||
# A wrapper script to run the security linter via Docker.
|
||||
# Install this script to /usr/local/bin/ for system-wide access.
|
||||
#
|
||||
# Usage:
|
||||
# php-security-lint [options] <path>
|
||||
# php-security-lint app/
|
||||
# php-security-lint -s high -f json .
|
||||
#
|
||||
# Installation:
|
||||
# curl -o /usr/local/bin/php-security-lint https://raw.githubusercontent.com/your-org/php-laravel-security-linter/main/php-security-lint
|
||||
# chmod +x /usr/local/bin/php-security-lint
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Global variable to track container ID for cleanup
|
||||
CONTAINER_ID=""
|
||||
|
||||
# Cleanup function to stop container on interrupt
|
||||
cleanup() {
|
||||
if [[ -n "$CONTAINER_ID" ]]; then
|
||||
docker stop "$CONTAINER_ID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo ""
|
||||
exit 130
|
||||
}
|
||||
|
||||
# Trap SIGINT (Ctrl+C) and SIGTERM
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# Configuration
|
||||
DOCKER_IMAGE="${PHP_SECURITY_LINT_IMAGE:-php-security-linter:latest}"
|
||||
CONTAINER_TARGET="/target"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Show usage
|
||||
show_usage() {
|
||||
cat << 'EOF'
|
||||
PHP/Laravel Security Linter
|
||||
|
||||
Usage:
|
||||
php-security-lint [options] <path>
|
||||
|
||||
Examples:
|
||||
php-security-lint . # Scan current directory
|
||||
php-security-lint app/ # Scan app directory
|
||||
php-security-lint -s high . # Show only high+ severity
|
||||
php-security-lint -f json -o report.json . # Output as JSON
|
||||
|
||||
Options:
|
||||
-f, --format <format> Output format: text, json, html, sarif, markdown
|
||||
-s, --severity <level> Minimum severity: low, medium, high, critical
|
||||
-o, --output <file> Write output to file
|
||||
-l, --lang <lang> Language: ja, en
|
||||
-c, --context [N] Show N lines of code context (default: 3)
|
||||
-e, --exclude <pattern> Exclude pattern (can be used multiple times)
|
||||
-i, --include <pattern> Include pattern (overrides exclude)
|
||||
-d, --recursive-depth <n> Recursive analysis depth (default: 10)
|
||||
--include-vendor Include vendor directory
|
||||
--include-tests Include tests directory
|
||||
--no-colors Disable colored output
|
||||
-q, --quiet Suppress progress output
|
||||
--verbose Show detailed information
|
||||
-h, --help Show this help message
|
||||
--version Show version
|
||||
|
||||
Environment Variables:
|
||||
PHP_SECURITY_LINT_IMAGE Docker image to use (default: php-security-linter:latest)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Check if Docker is available
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker is not installed or not in PATH${NC}" >&2
|
||||
echo "Please install Docker: https://docs.docker.com/get-docker/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker daemon is not running${NC}" >&2
|
||||
echo "Please start the Docker daemon" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if image exists, build if necessary
|
||||
check_image() {
|
||||
if ! docker image inspect "$DOCKER_IMAGE" &> /dev/null; then
|
||||
echo -e "${YELLOW}Docker image '$DOCKER_IMAGE' not found.${NC}"
|
||||
|
||||
# Check if we're in the project directory with Dockerfile
|
||||
if [[ -f "Dockerfile" ]] && grep -q "security-linter" "Dockerfile" 2>/dev/null; then
|
||||
echo "Building image from local Dockerfile..."
|
||||
docker build -t "$DOCKER_IMAGE" .
|
||||
else
|
||||
echo -e "${RED}Error: Docker image '$DOCKER_IMAGE' not found.${NC}" >&2
|
||||
echo "" >&2
|
||||
echo "To build the image, run from the security-linter directory:" >&2
|
||||
echo " docker build -t php-security-linter:latest ." >&2
|
||||
echo "" >&2
|
||||
echo "Or pull from registry (if published):" >&2
|
||||
echo " docker pull your-org/php-security-linter:latest" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse arguments and find target path
|
||||
parse_args() {
|
||||
local args=()
|
||||
local target_path=""
|
||||
local output_file=""
|
||||
local has_output=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
show_usage
|
||||
exit 0
|
||||
;;
|
||||
--version)
|
||||
docker run --rm "$DOCKER_IMAGE" --version
|
||||
exit 0
|
||||
;;
|
||||
-o|--output)
|
||||
has_output=true
|
||||
output_file="$2"
|
||||
args+=("$1" "$CONTAINER_TARGET/$(basename "$2")")
|
||||
shift 2
|
||||
;;
|
||||
-f|--format|-s|--severity|-l|--lang|-e|--exclude|-i|--include|-d|--recursive-depth)
|
||||
args+=("$1" "$2")
|
||||
shift 2
|
||||
;;
|
||||
-c|--context)
|
||||
# Handle optional numeric argument (must be a number, not a path)
|
||||
if [[ -n "$2" ]] && [[ "$2" =~ ^[0-9]+$ ]] && [[ ! "$2" =~ / ]]; then
|
||||
args+=("$1" "$2")
|
||||
shift 2
|
||||
else
|
||||
args+=("$1")
|
||||
shift
|
||||
fi
|
||||
;;
|
||||
--include-vendor|--include-tests|--no-colors|--no-default-excludes|--show-excluded|-q|--quiet|--verbose)
|
||||
args+=("$1")
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
args+=("$1")
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
# This is the target path
|
||||
if [[ -z "$target_path" ]]; then
|
||||
target_path="$1"
|
||||
else
|
||||
args+=("$1")
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Default to current directory if no path specified
|
||||
if [[ -z "$target_path" ]]; then
|
||||
target_path="."
|
||||
fi
|
||||
|
||||
# Convert to absolute path
|
||||
if [[ "$target_path" == "." ]]; then
|
||||
TARGET_HOST_PATH="$(pwd)"
|
||||
TARGET_CONTAINER_PATH="$CONTAINER_TARGET"
|
||||
elif [[ "$target_path" == /* ]]; then
|
||||
TARGET_HOST_PATH="$target_path"
|
||||
TARGET_CONTAINER_PATH="$CONTAINER_TARGET"
|
||||
else
|
||||
TARGET_HOST_PATH="$(pwd)"
|
||||
TARGET_CONTAINER_PATH="$CONTAINER_TARGET/$target_path"
|
||||
fi
|
||||
|
||||
DOCKER_ARGS=("${args[@]}" "$TARGET_CONTAINER_PATH")
|
||||
OUTPUT_FILE="$output_file"
|
||||
HAS_OUTPUT="$has_output"
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
check_docker
|
||||
check_image
|
||||
parse_args "$@"
|
||||
|
||||
# Build docker run command
|
||||
local docker_cmd=(
|
||||
docker run --rm
|
||||
--init # Proper signal handling (Ctrl+C)
|
||||
)
|
||||
|
||||
# Allocate TTY and interactive mode if terminal is interactive
|
||||
if [[ -t 0 ]] && [[ -t 1 ]]; then
|
||||
docker_cmd+=(-it)
|
||||
elif [[ -t 1 ]]; then
|
||||
docker_cmd+=(-t)
|
||||
fi
|
||||
|
||||
docker_cmd+=(-v "$TARGET_HOST_PATH:$CONTAINER_TARGET:ro")
|
||||
|
||||
# If output file specified, we need write access to output directory
|
||||
if [[ "$HAS_OUTPUT" == "true" ]] && [[ -n "$OUTPUT_FILE" ]]; then
|
||||
local output_dir
|
||||
output_dir="$(dirname "$(pwd)/$OUTPUT_FILE")"
|
||||
mkdir -p "$output_dir"
|
||||
docker_cmd+=(-v "$output_dir:$CONTAINER_TARGET:rw")
|
||||
fi
|
||||
|
||||
# Add image and arguments
|
||||
docker_cmd+=("$DOCKER_IMAGE" "${DOCKER_ARGS[@]}")
|
||||
|
||||
# Execute
|
||||
"${docker_cmd[@]}"
|
||||
local exit_code=$?
|
||||
|
||||
# Copy output file if specified
|
||||
if [[ "$HAS_OUTPUT" == "true" ]] && [[ -n "$OUTPUT_FILE" ]] && [[ -f "$OUTPUT_FILE" ]]; then
|
||||
echo -e "${GREEN}Report written to: $OUTPUT_FILE${NC}"
|
||||
fi
|
||||
|
||||
exit $exit_code
|
||||
}
|
||||
|
||||
main "$@"
|
||||
337
src/Analyzer/CallGraphBuilder.php
Normal file
337
src/Analyzer/CallGraphBuilder.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Analyzer;
|
||||
|
||||
use PhpParser\Node;
|
||||
use PhpParser\NodeTraverser;
|
||||
use PhpParser\NodeVisitorAbstract;
|
||||
|
||||
/**
|
||||
* Builds a call graph from PHP AST for recursive analysis
|
||||
*/
|
||||
class CallGraphBuilder
|
||||
{
|
||||
/** @var array Function/method definitions: [name => [file, line, params, body]] */
|
||||
private array $definitions = [];
|
||||
|
||||
/** @var array Call relationships: [caller => [callees]] */
|
||||
private array $calls = [];
|
||||
|
||||
/** @var array Class method definitions: [className::methodName => definition] */
|
||||
private array $classMethods = [];
|
||||
|
||||
/** @var array Current file being analyzed */
|
||||
private string $currentFile = '';
|
||||
|
||||
/** @var string|null Current class context */
|
||||
private ?string $currentClass = null;
|
||||
|
||||
/** @var string|null Current function/method context */
|
||||
private ?string $currentFunction = null;
|
||||
|
||||
/**
|
||||
* Build call graph from AST
|
||||
*/
|
||||
public function buildFromAst(array $ast, string $filePath): void
|
||||
{
|
||||
$this->currentFile = $filePath;
|
||||
|
||||
$traverser = new NodeTraverser();
|
||||
$traverser->addVisitor(new class($this) extends NodeVisitorAbstract {
|
||||
private CallGraphBuilder $builder;
|
||||
|
||||
public function __construct(CallGraphBuilder $builder)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
public function enterNode(Node $node): ?int
|
||||
{
|
||||
$this->builder->processNode($node);
|
||||
return null;
|
||||
}
|
||||
|
||||
public function leaveNode(Node $node): ?int
|
||||
{
|
||||
$this->builder->leaveNodeContext($node);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
$traverser->traverse($ast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a node to build call graph
|
||||
*/
|
||||
public function processNode(Node $node): void
|
||||
{
|
||||
// Track class context
|
||||
if ($node instanceof Node\Stmt\Class_) {
|
||||
$this->currentClass = $node->namespacedName?->toString() ?? $node->name?->toString();
|
||||
}
|
||||
|
||||
// Track function definitions
|
||||
if ($node instanceof Node\Stmt\Function_) {
|
||||
$name = $node->namespacedName?->toString() ?? $node->name->toString();
|
||||
$this->currentFunction = $name;
|
||||
$this->definitions[$name] = [
|
||||
'file' => $this->currentFile,
|
||||
'line' => $node->getStartLine(),
|
||||
'params' => $this->extractParams($node->params),
|
||||
'node' => $node,
|
||||
];
|
||||
}
|
||||
|
||||
// Track method definitions
|
||||
if ($node instanceof Node\Stmt\ClassMethod && $this->currentClass) {
|
||||
$name = $this->currentClass . '::' . $node->name->toString();
|
||||
$this->currentFunction = $name;
|
||||
$this->classMethods[$name] = [
|
||||
'file' => $this->currentFile,
|
||||
'line' => $node->getStartLine(),
|
||||
'params' => $this->extractParams($node->params),
|
||||
'visibility' => $this->getVisibility($node),
|
||||
'static' => $node->isStatic(),
|
||||
'node' => $node,
|
||||
];
|
||||
}
|
||||
|
||||
// Track function/method calls
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$callee = $this->getFunctionName($node);
|
||||
if ($callee && $this->currentFunction) {
|
||||
$this->addCall($this->currentFunction, $callee, $node->getStartLine());
|
||||
}
|
||||
}
|
||||
|
||||
// Track method calls
|
||||
if ($node instanceof Node\Expr\MethodCall) {
|
||||
$callee = $this->getMethodCallName($node);
|
||||
if ($callee && $this->currentFunction) {
|
||||
$this->addCall($this->currentFunction, $callee, $node->getStartLine());
|
||||
}
|
||||
}
|
||||
|
||||
// Track static method calls
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
$callee = $this->getStaticCallName($node);
|
||||
if ($callee && $this->currentFunction) {
|
||||
$this->addCall($this->currentFunction, $callee, $node->getStartLine());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave node context
|
||||
*/
|
||||
public function leaveNodeContext(Node $node): void
|
||||
{
|
||||
if ($node instanceof Node\Stmt\Class_) {
|
||||
$this->currentClass = null;
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) {
|
||||
$this->currentFunction = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameter information
|
||||
*/
|
||||
private function extractParams(array $params): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($params as $param) {
|
||||
$result[] = [
|
||||
'name' => '$' . $param->var->name,
|
||||
'type' => $param->type ? $this->getTypeName($param->type) : null,
|
||||
'default' => $param->default !== null,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type name from node
|
||||
*/
|
||||
private function getTypeName(?Node $type): ?string
|
||||
{
|
||||
if ($type === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($type instanceof Node\Identifier) {
|
||||
return $type->toString();
|
||||
}
|
||||
|
||||
if ($type instanceof Node\Name) {
|
||||
return $type->toString();
|
||||
}
|
||||
|
||||
if ($type instanceof Node\NullableType) {
|
||||
return '?' . $this->getTypeName($type->type);
|
||||
}
|
||||
|
||||
if ($type instanceof Node\UnionType) {
|
||||
return implode('|', array_map(fn($t) => $this->getTypeName($t), $type->types));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visibility modifier
|
||||
*/
|
||||
private function getVisibility(Node\Stmt\ClassMethod $node): string
|
||||
{
|
||||
if ($node->isPublic()) {
|
||||
return 'public';
|
||||
}
|
||||
if ($node->isProtected()) {
|
||||
return 'protected';
|
||||
}
|
||||
if ($node->isPrivate()) {
|
||||
return 'private';
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get function name from FuncCall
|
||||
*/
|
||||
private function getFunctionName(Node\Expr\FuncCall $node): ?string
|
||||
{
|
||||
if ($node->name instanceof Node\Name) {
|
||||
return $node->name->toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get method call name
|
||||
*/
|
||||
private function getMethodCallName(Node\Expr\MethodCall $node): ?string
|
||||
{
|
||||
if ($node->name instanceof Node\Identifier) {
|
||||
// Try to determine class from variable
|
||||
$varName = $this->getVariableName($node->var);
|
||||
return $varName ? "{$varName}->{$node->name->toString()}" : "->{$node->name->toString()}";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get static call name
|
||||
*/
|
||||
private function getStaticCallName(Node\Expr\StaticCall $node): ?string
|
||||
{
|
||||
$class = null;
|
||||
if ($node->class instanceof Node\Name) {
|
||||
$class = $node->class->toString();
|
||||
}
|
||||
|
||||
$method = null;
|
||||
if ($node->name instanceof Node\Identifier) {
|
||||
$method = $node->name->toString();
|
||||
}
|
||||
|
||||
if ($class && $method) {
|
||||
return "{$class}::{$method}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variable name
|
||||
*/
|
||||
private function getVariableName(Node $node): ?string
|
||||
{
|
||||
if ($node instanceof Node\Expr\Variable) {
|
||||
return is_string($node->name) ? '$' . $node->name : null;
|
||||
}
|
||||
if ($node instanceof Node\Expr\PropertyFetch) {
|
||||
$var = $this->getVariableName($node->var);
|
||||
$prop = $node->name instanceof Node\Identifier ? $node->name->toString() : null;
|
||||
return $var && $prop ? "{$var}->{$prop}" : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a call relationship
|
||||
*/
|
||||
private function addCall(string $caller, string $callee, int $line): void
|
||||
{
|
||||
if (!isset($this->calls[$caller])) {
|
||||
$this->calls[$caller] = [];
|
||||
}
|
||||
|
||||
$this->calls[$caller][] = [
|
||||
'callee' => $callee,
|
||||
'line' => $line,
|
||||
'file' => $this->currentFile,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete call graph
|
||||
*/
|
||||
public function getCallGraph(): array
|
||||
{
|
||||
return [
|
||||
'definitions' => $this->definitions,
|
||||
'classMethods' => $this->classMethods,
|
||||
'calls' => $this->calls,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all callers of a function/method
|
||||
*/
|
||||
public function findCallers(string $functionName): array
|
||||
{
|
||||
$callers = [];
|
||||
|
||||
foreach ($this->calls as $caller => $callees) {
|
||||
foreach ($callees as $call) {
|
||||
if ($call['callee'] === $functionName || str_ends_with($call['callee'], "::{$functionName}")) {
|
||||
$callers[] = [
|
||||
'caller' => $caller,
|
||||
'line' => $call['line'],
|
||||
'file' => $call['file'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $callers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all callees of a function/method
|
||||
*/
|
||||
public function findCallees(string $functionName): array
|
||||
{
|
||||
return $this->calls[$functionName] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get function/method definition
|
||||
*/
|
||||
public function getDefinition(string $name): ?array
|
||||
{
|
||||
return $this->definitions[$name] ?? $this->classMethods[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if function exists in call graph
|
||||
*/
|
||||
public function hasFunction(string $name): bool
|
||||
{
|
||||
return isset($this->definitions[$name]) || isset($this->classMethods[$name]);
|
||||
}
|
||||
}
|
||||
142
src/Analyzer/FileAnalyzer.php
Normal file
142
src/Analyzer/FileAnalyzer.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Analyzer;
|
||||
|
||||
use PhpParser\ParserFactory;
|
||||
use PhpParser\Parser;
|
||||
use PhpParser\NodeTraverser;
|
||||
use PhpParser\NodeVisitor\NameResolver;
|
||||
use PhpParser\Error;
|
||||
|
||||
/**
|
||||
* Parses PHP files into AST
|
||||
*/
|
||||
class FileAnalyzer
|
||||
{
|
||||
private Parser $parser;
|
||||
private array $errors = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->parser = (new ParserFactory())->createForNewestSupportedVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PHP code into AST
|
||||
*/
|
||||
public function parse(string $code, string $filePath = ''): ?array
|
||||
{
|
||||
try {
|
||||
$ast = $this->parser->parse($code);
|
||||
|
||||
if ($ast === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve names to fully qualified
|
||||
$traverser = new NodeTraverser();
|
||||
$traverser->addVisitor(new NameResolver());
|
||||
$ast = $traverser->traverse($ast);
|
||||
|
||||
return $ast;
|
||||
} catch (Error $e) {
|
||||
$this->errors[] = [
|
||||
'file' => $filePath,
|
||||
'message' => $e->getMessage(),
|
||||
'line' => $e->getStartLine(),
|
||||
];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Blade template
|
||||
*/
|
||||
public function parseBlade(string $code, string $filePath): array
|
||||
{
|
||||
$result = [
|
||||
'raw_outputs' => [], // {!! $var !!}
|
||||
'escaped_outputs' => [], // {{ $var }}
|
||||
'php_blocks' => [], // @php ... @endphp
|
||||
'includes' => [], // @include, @extends, @component
|
||||
'forms' => [], // <form> tags
|
||||
'csrf_tokens' => [], // @csrf directives
|
||||
];
|
||||
|
||||
// Find raw (unescaped) outputs - security risk
|
||||
preg_match_all('/\{!!\s*(.+?)\s*!!\}/s', $code, $matches, PREG_OFFSET_CAPTURE);
|
||||
foreach ($matches[1] as $match) {
|
||||
$result['raw_outputs'][] = [
|
||||
'expression' => $match[0],
|
||||
'line' => $this->getLineNumber($code, $match[1]),
|
||||
];
|
||||
}
|
||||
|
||||
// Find escaped outputs
|
||||
preg_match_all('/\{\{\s*(.+?)\s*\}\}/s', $code, $matches, PREG_OFFSET_CAPTURE);
|
||||
foreach ($matches[1] as $match) {
|
||||
$result['escaped_outputs'][] = [
|
||||
'expression' => $match[0],
|
||||
'line' => $this->getLineNumber($code, $match[1]),
|
||||
];
|
||||
}
|
||||
|
||||
// Find @php blocks
|
||||
preg_match_all('/@php\s*(.*?)\s*@endphp/s', $code, $matches, PREG_OFFSET_CAPTURE);
|
||||
foreach ($matches[1] as $match) {
|
||||
$result['php_blocks'][] = [
|
||||
'code' => $match[0],
|
||||
'line' => $this->getLineNumber($code, $match[1]),
|
||||
];
|
||||
}
|
||||
|
||||
// Find includes
|
||||
preg_match_all('/@(include|extends|component)\s*\(\s*[\'"](.+?)[\'"]/s', $code, $matches, PREG_OFFSET_CAPTURE);
|
||||
foreach ($matches[2] as $i => $match) {
|
||||
$result['includes'][] = [
|
||||
'type' => $matches[1][$i][0],
|
||||
'path' => $match[0],
|
||||
'line' => $this->getLineNumber($code, $match[1]),
|
||||
];
|
||||
}
|
||||
|
||||
// Find forms
|
||||
preg_match_all('/<form[^>]*>/i', $code, $matches, PREG_OFFSET_CAPTURE);
|
||||
foreach ($matches[0] as $match) {
|
||||
$line = $this->getLineNumber($code, $match[1]);
|
||||
$result['forms'][] = [
|
||||
'tag' => $match[0],
|
||||
'line' => $line,
|
||||
'has_method' => stripos($match[0], 'method=') !== false,
|
||||
];
|
||||
}
|
||||
|
||||
// Find CSRF tokens
|
||||
preg_match_all('/@csrf/i', $code, $matches, PREG_OFFSET_CAPTURE);
|
||||
foreach ($matches[0] as $match) {
|
||||
$result['csrf_tokens'][] = [
|
||||
'line' => $this->getLineNumber($code, $match[1]),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get line number from offset
|
||||
*/
|
||||
private function getLineNumber(string $code, int $offset): int
|
||||
{
|
||||
return substr_count(substr($code, 0, $offset), "\n") + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parse errors
|
||||
*/
|
||||
public function getErrors(): array
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
}
|
||||
520
src/Analyzer/TaintPreprocessor.php
Normal file
520
src/Analyzer/TaintPreprocessor.php
Normal file
@@ -0,0 +1,520 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Analyzer;
|
||||
|
||||
use PhpParser\Node;
|
||||
use PhpParser\NodeTraverser;
|
||||
use PhpParser\NodeVisitorAbstract;
|
||||
|
||||
/**
|
||||
* Preprocessor that analyzes AST to mark tainted variables
|
||||
*
|
||||
* This runs before the main analysis to propagate taint information
|
||||
* from user input sources through assignments.
|
||||
*/
|
||||
class TaintPreprocessor
|
||||
{
|
||||
private TaintTracker $taintTracker;
|
||||
private string $currentFile = '';
|
||||
private ?string $currentClass = null;
|
||||
private ?string $currentMethod = null;
|
||||
|
||||
/** @var array Tracks which function parameters receive tainted data */
|
||||
private array $taintedParams = [];
|
||||
|
||||
public function __construct(TaintTracker $taintTracker)
|
||||
{
|
||||
$this->taintTracker = $taintTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get variable name from a Variable node
|
||||
*/
|
||||
private function safeGetVarName(Node\Expr\Variable $node): string
|
||||
{
|
||||
return is_string($node->name) ? $node->name : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess AST to mark tainted variables
|
||||
*/
|
||||
public function process(array $ast, string $filePath): void
|
||||
{
|
||||
$this->currentFile = $filePath;
|
||||
|
||||
// First pass: collect function/method definitions and their parameters
|
||||
$this->collectDefinitions($ast);
|
||||
|
||||
// Second pass: track taint propagation through assignments
|
||||
$this->trackTaintPropagation($ast);
|
||||
|
||||
// Third pass: propagate taint through function calls
|
||||
$this->propagateThroughCalls($ast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect function and method definitions
|
||||
*/
|
||||
private function collectDefinitions(array $ast): void
|
||||
{
|
||||
$traverser = new NodeTraverser();
|
||||
$self = $this;
|
||||
|
||||
$traverser->addVisitor(new class($self) extends NodeVisitorAbstract {
|
||||
private TaintPreprocessor $processor;
|
||||
|
||||
public function __construct(TaintPreprocessor $processor)
|
||||
{
|
||||
$this->processor = $processor;
|
||||
}
|
||||
|
||||
public function enterNode(Node $node): ?int
|
||||
{
|
||||
if ($node instanceof Node\Stmt\Class_) {
|
||||
$this->processor->setCurrentClass($node->name?->toString());
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Stmt\ClassMethod) {
|
||||
$this->processor->setCurrentMethod($node->name->toString());
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Stmt\Function_) {
|
||||
$this->processor->setCurrentMethod($node->name->toString());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function leaveNode(Node $node): ?int
|
||||
{
|
||||
if ($node instanceof Node\Stmt\Class_) {
|
||||
$this->processor->setCurrentClass(null);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) {
|
||||
$this->processor->setCurrentMethod(null);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
$traverser->traverse($ast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track taint propagation through assignments
|
||||
*/
|
||||
private function trackTaintPropagation(array $ast): void
|
||||
{
|
||||
$traverser = new NodeTraverser();
|
||||
$self = $this;
|
||||
|
||||
$traverser->addVisitor(new class($self) extends NodeVisitorAbstract {
|
||||
private TaintPreprocessor $processor;
|
||||
private ?string $currentClass = null;
|
||||
private ?string $currentMethod = null;
|
||||
|
||||
public function __construct(TaintPreprocessor $processor)
|
||||
{
|
||||
$this->processor = $processor;
|
||||
}
|
||||
|
||||
public function enterNode(Node $node): ?int
|
||||
{
|
||||
if ($node instanceof Node\Stmt\Class_) {
|
||||
$this->currentClass = $node->name?->toString();
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Stmt\ClassMethod) {
|
||||
$this->currentMethod = $node->name->toString();
|
||||
// Check if method parameters come from tainted sources (controller methods)
|
||||
$this->processor->checkMethodParams($node, $this->currentClass);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Stmt\Function_) {
|
||||
$this->currentMethod = $node->name->toString();
|
||||
}
|
||||
|
||||
// Check assignments
|
||||
if ($node instanceof Node\Expr\Assign) {
|
||||
$this->processor->processAssignment($node);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function leaveNode(Node $node): ?int
|
||||
{
|
||||
if ($node instanceof Node\Stmt\Class_) {
|
||||
$this->currentClass = null;
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) {
|
||||
$this->currentMethod = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
$traverser->traverse($ast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate taint through function calls
|
||||
*/
|
||||
private function propagateThroughCalls(array $ast): void
|
||||
{
|
||||
// Multiple iterations to handle nested calls
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$traverser = new NodeTraverser();
|
||||
$self = $this;
|
||||
|
||||
$traverser->addVisitor(new class($self) extends NodeVisitorAbstract {
|
||||
private TaintPreprocessor $processor;
|
||||
|
||||
public function __construct(TaintPreprocessor $processor)
|
||||
{
|
||||
$this->processor = $processor;
|
||||
}
|
||||
|
||||
public function enterNode(Node $node): ?int
|
||||
{
|
||||
// Check method calls where we pass tainted data
|
||||
if ($node instanceof Node\Expr\MethodCall) {
|
||||
$this->processor->processMethodCall($node);
|
||||
}
|
||||
|
||||
// Check function calls
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$this->processor->processFunctionCall($node);
|
||||
}
|
||||
|
||||
// Check assignments from function return values
|
||||
if ($node instanceof Node\Expr\Assign) {
|
||||
$this->processor->processAssignmentFromCall($node);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
$traverser->traverse($ast);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check controller method parameters (Request $request)
|
||||
*/
|
||||
public function checkMethodParams(Node\Stmt\ClassMethod $node, ?string $className): void
|
||||
{
|
||||
foreach ($node->params as $param) {
|
||||
$typeName = $this->getTypeName($param->type);
|
||||
|
||||
// Request parameter in controller is always tainted
|
||||
if ($typeName && (
|
||||
$typeName === 'Request' ||
|
||||
str_contains($typeName, 'Request') ||
|
||||
str_contains($typeName, 'Illuminate\\Http\\Request')
|
||||
)) {
|
||||
$varName = '$' . $param->var->name;
|
||||
$this->taintTracker->markTainted($varName, $this->currentFile, $param);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process assignment to detect taint propagation
|
||||
*/
|
||||
public function processAssignment(Node\Expr\Assign $node): void
|
||||
{
|
||||
// Get variable name being assigned
|
||||
$varName = $this->getAssignedVarName($node->var);
|
||||
if ($varName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the right side is tainted
|
||||
if ($this->isExpressionTainted($node->expr)) {
|
||||
$this->taintTracker->markTainted($varName, $this->currentFile, $node->expr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process assignment from function/method call return
|
||||
*/
|
||||
public function processAssignmentFromCall(Node\Expr\Assign $node): void
|
||||
{
|
||||
$varName = $this->getAssignedVarName($node->var);
|
||||
if ($varName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the expression is a method/function call that might return tainted data
|
||||
if ($node->expr instanceof Node\Expr\MethodCall ||
|
||||
$node->expr instanceof Node\Expr\FuncCall ||
|
||||
$node->expr instanceof Node\Expr\StaticCall) {
|
||||
|
||||
// Check if any argument to the call is tainted
|
||||
$args = $node->expr->args ?? [];
|
||||
foreach ($args as $arg) {
|
||||
// Skip VariadicPlaceholder (spread operator ...)
|
||||
if (!$arg instanceof Node\Arg) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isExpressionTainted($arg->value)) {
|
||||
// The function receives tainted data, so its return might be tainted
|
||||
// (unless it's a known sanitizer)
|
||||
$funcName = $this->getCallName($node->expr);
|
||||
if ($funcName && !$this->taintTracker->isSanitizer($funcName)) {
|
||||
$this->taintTracker->markTainted($varName, $this->currentFile, $node->expr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process method call to track taint flow
|
||||
*/
|
||||
public function processMethodCall(Node\Expr\MethodCall $node): void
|
||||
{
|
||||
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : null;
|
||||
if ($methodName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track tainted arguments to method calls
|
||||
foreach ($node->args as $index => $arg) {
|
||||
// Skip VariadicPlaceholder (spread operator ...)
|
||||
if (!$arg instanceof Node\Arg) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isExpressionTainted($arg->value)) {
|
||||
$key = ($this->currentClass ? $this->currentClass . '::' : '') . $methodName . ':' . $index;
|
||||
$this->taintedParams[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process function call to track taint flow
|
||||
*/
|
||||
public function processFunctionCall(Node\Expr\FuncCall $node): void
|
||||
{
|
||||
$funcName = $this->getCallName($node);
|
||||
if ($funcName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track tainted arguments
|
||||
foreach ($node->args as $index => $arg) {
|
||||
// Skip VariadicPlaceholder (spread operator ...)
|
||||
if (!$arg instanceof Node\Arg) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isExpressionTainted($arg->value)) {
|
||||
$key = $funcName . ':' . $index;
|
||||
$this->taintedParams[$key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an expression is tainted
|
||||
*/
|
||||
private function isExpressionTainted(Node $expr): bool
|
||||
{
|
||||
// Check direct taint via TaintTracker
|
||||
if ($this->taintTracker->isTainted($expr, $this->currentFile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for direct user input patterns
|
||||
if ($this->isDirectUserInput($expr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check variable against marked tainted variables
|
||||
if ($expr instanceof Node\Expr\Variable) {
|
||||
$varName = '$' . ($this->safeGetVarName($expr));
|
||||
return $this->taintTracker->isTainted($expr, $this->currentFile);
|
||||
}
|
||||
|
||||
// Check concatenation
|
||||
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $this->isExpressionTainted($expr->left) || $this->isExpressionTainted($expr->right);
|
||||
}
|
||||
|
||||
// Check string interpolation
|
||||
if ($expr instanceof Node\Scalar\Encapsed) {
|
||||
foreach ($expr->parts as $part) {
|
||||
if ($this->isExpressionTainted($part)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for direct user input patterns
|
||||
*/
|
||||
private function isDirectUserInput(Node $expr): bool
|
||||
{
|
||||
// $request->input(), $request->get(), etc.
|
||||
if ($expr instanceof Node\Expr\MethodCall) {
|
||||
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : '';
|
||||
$taintedMethods = ['input', 'get', 'post', 'query', 'all', 'only', 'except', 'file', 'cookie'];
|
||||
|
||||
if (in_array($methodName, $taintedMethods)) {
|
||||
// Check if called on $request
|
||||
if ($expr->var instanceof Node\Expr\Variable) {
|
||||
$varName = $this->safeGetVarName($expr->var);
|
||||
if ($varName === 'request') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for request() helper chain
|
||||
if ($expr->var instanceof Node\Expr\FuncCall) {
|
||||
$funcName = $this->getCallName($expr->var);
|
||||
if ($funcName === 'request') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// $_GET, $_POST, etc.
|
||||
if ($expr instanceof Node\Expr\ArrayDimFetch) {
|
||||
if ($expr->var instanceof Node\Expr\Variable) {
|
||||
$varName = $this->safeGetVarName($expr->var);
|
||||
if (in_array($varName, ['_GET', '_POST', '_REQUEST', '_COOKIE', '_FILES', '_SERVER'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Recursive check for nested array access
|
||||
if ($expr->var instanceof Node\Expr\ArrayDimFetch) {
|
||||
return $this->isDirectUserInput($expr->var);
|
||||
}
|
||||
}
|
||||
|
||||
// Request::input(), etc.
|
||||
if ($expr instanceof Node\Expr\StaticCall) {
|
||||
$className = $expr->class instanceof Node\Name ? $expr->class->toString() : '';
|
||||
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : '';
|
||||
|
||||
if (str_contains($className, 'Request')) {
|
||||
$taintedMethods = ['input', 'get', 'post', 'query', 'all'];
|
||||
if (in_array($methodName, $taintedMethods)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the variable name from an assignment target
|
||||
*/
|
||||
private function getAssignedVarName(Node $node): ?string
|
||||
{
|
||||
if ($node instanceof Node\Expr\Variable) {
|
||||
if (!is_string($node->name)) {
|
||||
return null; // Variable variables not supported
|
||||
}
|
||||
return '$' . $node->name;
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\PropertyFetch) {
|
||||
// Handle $this->property
|
||||
$var = $this->getAssignedVarName($node->var);
|
||||
$prop = $node->name instanceof Node\Identifier ? $node->name->toString() : null;
|
||||
if ($var && $prop) {
|
||||
return "{$var}->{$prop}";
|
||||
}
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\ArrayDimFetch) {
|
||||
return $this->getAssignedVarName($node->var);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type name from type node
|
||||
*/
|
||||
private function getTypeName(?Node $type): ?string
|
||||
{
|
||||
if ($type === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($type instanceof Node\Identifier) {
|
||||
return $type->toString();
|
||||
}
|
||||
|
||||
if ($type instanceof Node\Name) {
|
||||
return $type->toString();
|
||||
}
|
||||
|
||||
if ($type instanceof Node\NullableType) {
|
||||
return $this->getTypeName($type->type);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get function/method name from call
|
||||
*/
|
||||
private function getCallName(Node $node): ?string
|
||||
{
|
||||
if ($node instanceof Node\Expr\FuncCall && $node->name instanceof Node\Name) {
|
||||
return $node->name->toString();
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\MethodCall && $node->name instanceof Node\Identifier) {
|
||||
return $node->name->toString();
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\StaticCall && $node->name instanceof Node\Identifier) {
|
||||
$class = $node->class instanceof Node\Name ? $node->class->toString() : '';
|
||||
return $class . '::' . $node->name->toString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current class context
|
||||
*/
|
||||
public function setCurrentClass(?string $class): void
|
||||
{
|
||||
$this->currentClass = $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current method context
|
||||
*/
|
||||
public function setCurrentMethod(?string $method): void
|
||||
{
|
||||
$this->currentMethod = $method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tainted parameters
|
||||
*/
|
||||
public function getTaintedParams(): array
|
||||
{
|
||||
return $this->taintedParams;
|
||||
}
|
||||
}
|
||||
594
src/Analyzer/TaintTracker.php
Normal file
594
src/Analyzer/TaintTracker.php
Normal file
@@ -0,0 +1,594 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Analyzer;
|
||||
|
||||
use PhpParser\Node;
|
||||
use PhpParser\NodeTraverser;
|
||||
use PhpParser\NodeVisitorAbstract;
|
||||
|
||||
/**
|
||||
* Tracks tainted data flow from sources to sinks
|
||||
*
|
||||
* Sources: User input ($_GET, $_POST, $_REQUEST, $request->input(), etc.)
|
||||
* Sinks: Dangerous functions (echo, eval, DB::raw, exec, etc.)
|
||||
* Sanitizers: Functions that clean data (htmlspecialchars, escape, etc.)
|
||||
*/
|
||||
class TaintTracker
|
||||
{
|
||||
/** @var array User input sources */
|
||||
private const SOURCES = [
|
||||
// PHP Superglobals
|
||||
'$_GET', '$_POST', '$_REQUEST', '$_COOKIE', '$_FILES', '$_SERVER',
|
||||
// Laravel Request methods
|
||||
'$request->input', '$request->get', '$request->post', '$request->query',
|
||||
'$request->all', '$request->only', '$request->except',
|
||||
'$request->file', '$request->cookie',
|
||||
'request()->input', 'request()->get', 'request()->all',
|
||||
'Request::input', 'Request::get', 'Request::all',
|
||||
// Raw input
|
||||
'file_get_contents(\'php://input\')',
|
||||
'php://input',
|
||||
];
|
||||
|
||||
/** @var array Functions that sanitize data */
|
||||
private const SANITIZERS = [
|
||||
// XSS sanitizers
|
||||
'htmlspecialchars', 'htmlentities', 'strip_tags',
|
||||
'e', // Laravel helper
|
||||
// SQL sanitizers
|
||||
'addslashes', 'mysqli_real_escape_string', 'pg_escape_string',
|
||||
// Path sanitizers
|
||||
'basename', 'realpath',
|
||||
// Validation (Laravel)
|
||||
'validate', 'validated',
|
||||
// Type casting
|
||||
'(int)', '(float)', '(bool)', 'intval', 'floatval', 'boolval',
|
||||
];
|
||||
|
||||
/** @var array Variables currently tainted */
|
||||
private array $taintedVariables = [];
|
||||
|
||||
/** @var array Taint propagation history */
|
||||
private array $taintHistory = [];
|
||||
|
||||
/** @var array Call graph for recursive analysis */
|
||||
private array $callGraph = [];
|
||||
|
||||
/** @var int Maximum recursion depth */
|
||||
private int $maxDepth;
|
||||
|
||||
/** @var array Cache for analyzed functions */
|
||||
private array $analysisCache = [];
|
||||
|
||||
public function __construct(int $maxDepth = 10)
|
||||
{
|
||||
$this->maxDepth = $maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set call graph for cross-function analysis
|
||||
*/
|
||||
public function setCallGraph(array $callGraph): void
|
||||
{
|
||||
$this->callGraph = $callGraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an expression is tainted (contains user input)
|
||||
*/
|
||||
public function isTainted(Node $node, string $filePath, int $depth = 0): bool
|
||||
{
|
||||
if ($depth > $this->maxDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a direct source
|
||||
if ($this->isSource($node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check variable
|
||||
if ($node instanceof Node\Expr\Variable) {
|
||||
if (!is_string($node->name)) {
|
||||
// Variable variables ($$foo) - assume tainted for safety
|
||||
return true;
|
||||
}
|
||||
$varName = '$' . $node->name;
|
||||
return $this->isVariableTainted($varName, $filePath);
|
||||
}
|
||||
|
||||
// Check array access (e.g., $_GET['foo'])
|
||||
if ($node instanceof Node\Expr\ArrayDimFetch) {
|
||||
return $this->isTainted($node->var, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Check property fetch (e.g., $request->input)
|
||||
if ($node instanceof Node\Expr\PropertyFetch) {
|
||||
return $this->isPropertyFetchTainted($node, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Check method call (e.g., $request->input('foo'))
|
||||
if ($node instanceof Node\Expr\MethodCall) {
|
||||
return $this->isMethodCallTainted($node, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Check static call (e.g., Request::input('foo'))
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
return $this->isStaticCallTainted($node, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Check function call (e.g., request()->input('foo'))
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
return $this->isFunctionCallTainted($node, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Check binary operations (concatenation propagates taint)
|
||||
if ($node instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $this->isTainted($node->left, $filePath, $depth)
|
||||
|| $this->isTainted($node->right, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Check encapsed string (string interpolation)
|
||||
if ($node instanceof Node\Scalar\Encapsed) {
|
||||
foreach ($node->parts as $part) {
|
||||
if ($this->isTainted($part, $filePath, $depth)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check ternary
|
||||
if ($node instanceof Node\Expr\Ternary) {
|
||||
return $this->isTainted($node->if ?? $node->cond, $filePath, $depth)
|
||||
|| $this->isTainted($node->else, $filePath, $depth);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node is a direct source of user input
|
||||
*/
|
||||
public function isSource(Node $node): bool
|
||||
{
|
||||
// Check superglobals
|
||||
if ($node instanceof Node\Expr\Variable) {
|
||||
// Variable name can be a string or an expression (variable variables)
|
||||
if (!is_string($node->name)) {
|
||||
return false;
|
||||
}
|
||||
$name = '$' . $node->name;
|
||||
return in_array($name, ['$_GET', '$_POST', '$_REQUEST', '$_COOKIE', '$_FILES', '$_SERVER']);
|
||||
}
|
||||
|
||||
// Check array access to superglobals
|
||||
if ($node instanceof Node\Expr\ArrayDimFetch) {
|
||||
return $this->isSource($node->var);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variable is tainted
|
||||
*/
|
||||
private function isVariableTainted(string $varName, string $filePath): bool
|
||||
{
|
||||
$key = "{$filePath}:{$varName}";
|
||||
return isset($this->taintedVariables[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a variable as tainted
|
||||
*/
|
||||
public function markTainted(string $varName, string $filePath, ?Node $source = null): void
|
||||
{
|
||||
$key = "{$filePath}:{$varName}";
|
||||
$this->taintedVariables[$key] = [
|
||||
'source' => $source,
|
||||
'file' => $filePath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a variable as clean (sanitized)
|
||||
*/
|
||||
public function markClean(string $varName, string $filePath): void
|
||||
{
|
||||
$key = "{$filePath}:{$varName}";
|
||||
unset($this->taintedVariables[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get variable name from a Variable node
|
||||
*/
|
||||
private function safeGetVarName(Node\Expr\Variable $node): string
|
||||
{
|
||||
return is_string($node->name) ? $node->name : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if property fetch is tainted
|
||||
*/
|
||||
private function isPropertyFetchTainted(Node\Expr\PropertyFetch $node, string $filePath, int $depth): bool
|
||||
{
|
||||
// Check for $request->property patterns
|
||||
if ($node->var instanceof Node\Expr\Variable) {
|
||||
$varName = $this->safeGetVarName($node->var);
|
||||
$propName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
|
||||
|
||||
if ($varName === 'request') {
|
||||
// Common tainted request properties
|
||||
$taintedProps = ['input', 'query', 'post', 'all', 'cookie', 'file'];
|
||||
return in_array($propName, $taintedProps);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->isTainted($node->var, $filePath, $depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if method call is tainted
|
||||
*/
|
||||
private function isMethodCallTainted(Node\Expr\MethodCall $node, string $filePath, int $depth): bool
|
||||
{
|
||||
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
|
||||
|
||||
// Check for $request->input() style calls
|
||||
if ($node->var instanceof Node\Expr\Variable) {
|
||||
$varName = $this->safeGetVarName($node->var);
|
||||
|
||||
if ($varName === 'request') {
|
||||
$taintedMethods = ['input', 'get', 'post', 'query', 'all', 'only', 'except', 'file', 'cookie'];
|
||||
if (in_array($methodName, $taintedMethods)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for chained request() helper
|
||||
if ($node->var instanceof Node\Expr\FuncCall) {
|
||||
if ($this->getFunctionName($node->var) === 'request') {
|
||||
$taintedMethods = ['input', 'get', 'post', 'query', 'all', 'only', 'except'];
|
||||
if (in_array($methodName, $taintedMethods)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a sanitizer method
|
||||
if ($this->isSanitizerMethod($methodName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recursively check if the object is tainted
|
||||
return $this->isTainted($node->var, $filePath, $depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if static call is tainted
|
||||
*/
|
||||
private function isStaticCallTainted(Node\Expr\StaticCall $node, string $filePath, int $depth): bool
|
||||
{
|
||||
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
|
||||
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
|
||||
|
||||
// Laravel Request facade
|
||||
if (in_array($className, ['Request', 'Illuminate\\Support\\Facades\\Request'])) {
|
||||
$taintedMethods = ['input', 'get', 'post', 'query', 'all', 'only', 'except'];
|
||||
if (in_array($methodName, $taintedMethods)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if function call is tainted
|
||||
*/
|
||||
private function isFunctionCallTainted(Node\Expr\FuncCall $node, string $filePath, int $depth): bool
|
||||
{
|
||||
$funcName = $this->getFunctionName($node);
|
||||
|
||||
if ($funcName === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's the request() helper
|
||||
if ($funcName === 'request') {
|
||||
// request() without parameters returns the Request object
|
||||
// which will be tainted when accessing input methods
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a sanitizer
|
||||
if ($this->isSanitizer($funcName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trace through user-defined functions (recursive analysis)
|
||||
return $this->traceFunction($funcName, $node->args, $filePath, $depth + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace taint through a user-defined function
|
||||
*/
|
||||
private function traceFunction(string $funcName, array $args, string $filePath, int $depth): bool
|
||||
{
|
||||
if ($depth > $this->maxDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
try {
|
||||
// Filter out VariadicPlaceholder nodes
|
||||
$filteredArgs = array_filter($args, fn($a) => $a instanceof Node\Arg);
|
||||
$cacheKey = "{$funcName}:" . serialize(array_map(fn($a) => $this->nodeToString($a->value), $filteredArgs));
|
||||
if (isset($this->analysisCache[$cacheKey])) {
|
||||
return $this->analysisCache[$cacheKey];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// If cache key generation fails, proceed without caching
|
||||
$cacheKey = null;
|
||||
}
|
||||
|
||||
// Check if any arguments are tainted
|
||||
$taintedArgs = [];
|
||||
foreach ($args as $i => $arg) {
|
||||
// Skip VariadicPlaceholder (spread operator ...)
|
||||
if (!$arg instanceof Node\Arg) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isTainted($arg->value, $filePath, $depth)) {
|
||||
$taintedArgs[] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($taintedArgs)) {
|
||||
if ($cacheKey !== null) {
|
||||
$this->analysisCache[$cacheKey] = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Look up function definition in call graph
|
||||
$definition = $this->callGraph['definitions'][$funcName] ?? null;
|
||||
|
||||
if ($definition === null) {
|
||||
// Try class methods
|
||||
foreach ($this->callGraph['classMethods'] ?? [] as $name => $def) {
|
||||
if (str_ends_with($name, "::{$funcName}")) {
|
||||
$definition = $def;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($definition === null) {
|
||||
// Unknown function - assume tainted if any arg is tainted
|
||||
if ($cacheKey !== null) {
|
||||
$this->analysisCache[$cacheKey] = !empty($taintedArgs);
|
||||
}
|
||||
return !empty($taintedArgs);
|
||||
}
|
||||
|
||||
// Check if tainted args flow to return value
|
||||
// This is a simplified analysis - could be more precise
|
||||
$result = $this->analyzeDataFlow($definition, $taintedArgs, $depth);
|
||||
if ($cacheKey !== null) {
|
||||
$this->analysisCache[$cacheKey] = $result;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze data flow within a function
|
||||
*/
|
||||
private function analyzeDataFlow(array $definition, array $taintedArgIndices, int $depth): bool
|
||||
{
|
||||
$node = $definition['node'] ?? null;
|
||||
if ($node === null) {
|
||||
return !empty($taintedArgIndices);
|
||||
}
|
||||
|
||||
// Get parameter names for tainted args
|
||||
$params = $definition['params'] ?? [];
|
||||
$taintedParams = [];
|
||||
foreach ($taintedArgIndices as $index) {
|
||||
if (isset($params[$index])) {
|
||||
$taintedParams[] = $params[$index]['name'];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($taintedParams)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any tainted parameter reaches a return statement
|
||||
// or is used in a way that could propagate taint
|
||||
$stmts = $node->stmts ?? [];
|
||||
return $this->checkTaintPropagation($stmts, $taintedParams, $depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tainted variables propagate to return
|
||||
*/
|
||||
private function checkTaintPropagation(array $stmts, array $taintedVars, int $depth): bool
|
||||
{
|
||||
foreach ($stmts as $stmt) {
|
||||
// Check return statements
|
||||
if ($stmt instanceof Node\Stmt\Return_ && $stmt->expr !== null) {
|
||||
if ($this->containsTaintedVar($stmt->expr, $taintedVars)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check assignments that might propagate taint
|
||||
if ($stmt instanceof Node\Stmt\Expression) {
|
||||
$expr = $stmt->expr;
|
||||
if ($expr instanceof Node\Expr\Assign) {
|
||||
if ($this->containsTaintedVar($expr->expr, $taintedVars)) {
|
||||
// Add assigned variable to tainted list
|
||||
if ($expr->var instanceof Node\Expr\Variable) {
|
||||
$taintedVars[] = '$' . $expr->var->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check nested statements
|
||||
if (isset($stmt->stmts)) {
|
||||
if ($this->checkTaintPropagation($stmt->stmts, $taintedVars, $depth)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if expression contains a tainted variable
|
||||
*/
|
||||
private function containsTaintedVar(Node $expr, array $taintedVars): bool
|
||||
{
|
||||
if ($expr instanceof Node\Expr\Variable) {
|
||||
$name = '$' . $this->safeGetVarName($expr);
|
||||
return in_array($name, $taintedVars);
|
||||
}
|
||||
|
||||
// Check sub-expressions
|
||||
foreach ($expr->getSubNodeNames() as $name) {
|
||||
$subNode = $expr->{$name};
|
||||
if ($subNode instanceof Node) {
|
||||
if ($this->containsTaintedVar($subNode, $taintedVars)) {
|
||||
return true;
|
||||
}
|
||||
} elseif (is_array($subNode)) {
|
||||
foreach ($subNode as $item) {
|
||||
if ($item instanceof Node && $this->containsTaintedVar($item, $taintedVars)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if function is a sanitizer
|
||||
*/
|
||||
public function isSanitizer(string $funcName): bool
|
||||
{
|
||||
$sanitizers = [
|
||||
'htmlspecialchars', 'htmlentities', 'strip_tags',
|
||||
'e', 'escape',
|
||||
'addslashes', 'mysqli_real_escape_string', 'pg_escape_string',
|
||||
'basename', 'realpath',
|
||||
'intval', 'floatval', 'boolval',
|
||||
'filter_var', 'filter_input',
|
||||
];
|
||||
|
||||
return in_array($funcName, $sanitizers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if method is a sanitizer
|
||||
*/
|
||||
public function isSanitizerMethod(string $methodName): bool
|
||||
{
|
||||
$sanitizers = ['validate', 'validated', 'escape', 'clean', 'sanitize'];
|
||||
return in_array($methodName, $sanitizers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get function name from FuncCall
|
||||
*/
|
||||
private function getFunctionName(Node\Expr\FuncCall $node): ?string
|
||||
{
|
||||
if ($node->name instanceof Node\Name) {
|
||||
return $node->name->toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert node to string representation for caching
|
||||
*/
|
||||
private function nodeToString(Node $node): string
|
||||
{
|
||||
if ($node instanceof Node\Expr\Variable) {
|
||||
// Variable name can be a string or another expression (variable variables like $$foo)
|
||||
if (is_string($node->name)) {
|
||||
return '$' . $node->name;
|
||||
}
|
||||
// For variable variables, return a placeholder
|
||||
return '$<expr>';
|
||||
}
|
||||
if ($node instanceof Node\Scalar\String_) {
|
||||
return "'{$node->value}'";
|
||||
}
|
||||
if ($node instanceof Node\Scalar\LNumber) {
|
||||
return (string) $node->value;
|
||||
}
|
||||
if ($node instanceof Node\Scalar\DNumber) {
|
||||
return (string) $node->value;
|
||||
}
|
||||
if ($node instanceof Node\Expr\ConstFetch) {
|
||||
return $node->name->toString();
|
||||
}
|
||||
if ($node instanceof Node\Expr\ClassConstFetch) {
|
||||
$class = $node->class instanceof Node\Name ? $node->class->toString() : '<expr>';
|
||||
$name = $node->name instanceof Node\Identifier ? $node->name->toString() : '<expr>';
|
||||
return "{$class}::{$name}";
|
||||
}
|
||||
if ($node instanceof Node\Expr\Array_) {
|
||||
return '[array]';
|
||||
}
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$name = $node->name instanceof Node\Name ? $node->name->toString() : '<expr>';
|
||||
return "{$name}(...)";
|
||||
}
|
||||
if ($node instanceof Node\Expr\MethodCall) {
|
||||
$method = $node->name instanceof Node\Identifier ? $node->name->toString() : '<expr>';
|
||||
return "->$method(...)";
|
||||
}
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
$class = $node->class instanceof Node\Name ? $node->class->toString() : '<expr>';
|
||||
$method = $node->name instanceof Node\Identifier ? $node->name->toString() : '<expr>';
|
||||
return "{$class}::{$method}(...)";
|
||||
}
|
||||
return get_class($node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get taint trace for debugging
|
||||
*/
|
||||
public function getTaintTrace(string $varName, string $filePath): array
|
||||
{
|
||||
$key = "{$filePath}:{$varName}";
|
||||
return $this->taintHistory[$key] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset taint state for new file
|
||||
*/
|
||||
public function resetForFile(string $filePath): void
|
||||
{
|
||||
// Keep cross-file taint but reset file-specific tracking
|
||||
$this->taintHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tainted variables
|
||||
*/
|
||||
public function getTaintedVariables(): array
|
||||
{
|
||||
return $this->taintedVariables;
|
||||
}
|
||||
}
|
||||
547
src/I18n/Messages.php
Normal file
547
src/I18n/Messages.php
Normal file
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\I18n;
|
||||
|
||||
/**
|
||||
* Internationalization support for security linter messages
|
||||
*/
|
||||
class Messages
|
||||
{
|
||||
private static string $locale = 'ja';
|
||||
private static ?Messages $instance = null;
|
||||
|
||||
private array $messages = [];
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$this->loadMessages();
|
||||
}
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function setLocale(string $locale): void
|
||||
{
|
||||
self::$locale = in_array($locale, ['ja', 'en']) ? $locale : 'ja';
|
||||
}
|
||||
|
||||
public static function getLocale(): string
|
||||
{
|
||||
return self::$locale;
|
||||
}
|
||||
|
||||
public static function get(string $key, array $params = []): string
|
||||
{
|
||||
$instance = self::getInstance();
|
||||
$message = $instance->messages[self::$locale][$key]
|
||||
?? $instance->messages['en'][$key]
|
||||
?? $key;
|
||||
|
||||
// Replace placeholders
|
||||
foreach ($params as $name => $value) {
|
||||
$message = str_replace(":{$name}", (string)$value, $message);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function loadMessages(): void
|
||||
{
|
||||
$this->messages = [
|
||||
'ja' => [
|
||||
// CLI messages
|
||||
'cli.banner' => 'PHP/Laravel セキュリティリンター',
|
||||
'cli.analyzing' => '解析中: :path',
|
||||
'cli.completed' => ':time 秒で完了',
|
||||
'cli.no_vulnerabilities' => 'セキュリティ脆弱性は見つかりませんでした。',
|
||||
'cli.report_written' => 'レポートを出力しました: :path',
|
||||
'cli.summary' => 'サマリー',
|
||||
'cli.total' => '合計',
|
||||
'cli.critical' => 'クリティカル',
|
||||
'cli.high' => '高',
|
||||
'cli.medium' => '中',
|
||||
'cli.low' => '低',
|
||||
'cli.file' => 'ファイル',
|
||||
'cli.line' => '行',
|
||||
'cli.recommendation' => '推奨対策',
|
||||
'cli.call_trace' => 'コールトレース',
|
||||
|
||||
// Severity labels
|
||||
'severity.critical' => 'クリティカル',
|
||||
'severity.high' => '高',
|
||||
'severity.medium' => '中',
|
||||
'severity.low' => '低',
|
||||
|
||||
// XSS messages
|
||||
'xss.name' => 'XSS (クロスサイトスクリプティング)',
|
||||
'xss.unescaped_echo' => 'エスケープされていない出力によりXSSの脆弱性があります。ユーザー入力が適切にエスケープされずに出力されています。',
|
||||
'xss.unescaped_echo.rec' => 'htmlspecialchars($var, ENT_QUOTES, \'UTF-8\') または Laravel の e() ヘルパーを使用してください。',
|
||||
'xss.unescaped_print' => 'エスケープされていない print によりXSSの脆弱性があります。',
|
||||
'xss.unescaped_print.rec' => 'htmlspecialchars() で出力をエスケープしてください。',
|
||||
'xss.blade_raw' => 'Blade の {!! !!} による生出力はXSSの脆弱性を引き起こす可能性があります。式: :expr',
|
||||
'xss.blade_raw.rec' => '自動エスケープされる {{ }} を使用するか、生出力の前にコンテンツがサニタイズされていることを確認してください。',
|
||||
'xss.blade_php_echo' => '@php ブロック内のエスケープされていない echo はXSSの脆弱性を引き起こす可能性があります。',
|
||||
'xss.blade_php_echo.rec' => 'Blade の {{ }} 構文を使用するか、htmlspecialchars() でエスケープしてください。',
|
||||
'xss.blade_js_context' => 'JavaScriptコンテキスト内のBlade出力は、エスケープされていてもXSSの脆弱性を引き起こす可能性があります。',
|
||||
'xss.blade_js_context.rec' => '@json() ディレクティブまたは JSON.parse() を適切なエンコーディングで使用してください。',
|
||||
'xss.blade_js_raw' => 'JavaScriptコンテキスト内の生のBlade出力 {!! !!} は深刻なXSS脆弱性です。式: :expr',
|
||||
'xss.blade_js_raw.rec' => 'JavaScriptで {!! !!} を絶対に使用しないでください。@json() または Js::from() を使用してください。',
|
||||
'xss.url_context' => 'URLコンテキスト (:attr 属性) 内のBlade出力は javascript: URL によるXSS脆弱性を引き起こす可能性があります。式: :expr',
|
||||
'xss.url_context.rec' => 'URL を検証して javascript:, data:, vbscript: プロトコルを除外するか、route() や url() ヘルパーを使用してください。',
|
||||
'xss.event_handler' => 'イベントハンドラ属性内のBlade出力はXSSの脆弱性を引き起こす可能性があります。式: :expr',
|
||||
'xss.event_handler.rec' => 'イベントハンドラに動的な値を渡さないでください。データ属性と JavaScript イベントリスナーを使用してください。',
|
||||
'xss.event_handler_raw' => 'イベントハンドラ属性内の生のBlade出力 {!! !!} は深刻なXSS脆弱性です。式: :expr',
|
||||
'xss.event_handler_raw.rec' => 'イベントハンドラに {!! !!} を絶対に使用しないでください。',
|
||||
'xss.style_injection' => 'style 属性内のBlade出力はCSSインジェクション脆弱性を引き起こす可能性があります。式: :expr',
|
||||
'xss.style_injection.rec' => 'CSS値を検証し、許可された値のホワイトリストを使用してください。',
|
||||
'xss.unquoted_attr' => '引用符なしの属性値 (:attr) 内のBlade出力は属性エスケープを壊す可能性があります。式: :expr',
|
||||
'xss.unquoted_attr.rec' => '属性値を常に引用符で囲んでください: :attr="{{ $value }}"',
|
||||
'xss.json_in_script' => 'scriptタグ内の @json() ディレクティブはXSSリスクがある可能性があります。式: :expr',
|
||||
'xss.json_in_script.rec' => 'JavaScript コンテキストでは Js::from() の使用を検討してください。JSONデータが適切にエスケープされていることを確認してください。',
|
||||
'xss.template_injection' => '@:directive ディレクティブに変数 $:var が使用されています。テンプレートインジェクションの脆弱性があります。',
|
||||
'xss.template_injection.rec' => '@include, @extends, @component にユーザー入力を絶対に使用しないでください。許可されたテンプレートのホワイトリストを使用してください。',
|
||||
'xss.svg_context' => 'SVGコンテキスト内のBlade出力はXSSの脆弱性を引き起こす可能性があります。式: :expr',
|
||||
'xss.svg_context.rec' => 'SVGは script タグやイベントハンドラを含む可能性があります。SVG内のユーザーデータは慎重にサニタイズしてください。',
|
||||
'xss.printf_tainted' => '汚染された引数を持つ printf はXSSの脆弱性を引き起こす可能性があります。',
|
||||
'xss.printf_tainted.rec' => 'printf で使用する前にユーザー入力をエスケープしてください: htmlspecialchars($input)',
|
||||
'xss.response_tainted' => '汚染されたデータを含むレスポンスはXSSの脆弱性を引き起こす可能性があります。',
|
||||
'xss.response_tainted.rec' => 'ビューに渡すデータが適切にエスケープされていることを確認してください。',
|
||||
'xss.header_tainted' => 'HTTPヘッダー内の汚染されたデータはヘッダーインジェクションを引き起こす可能性があります。',
|
||||
'xss.header_tainted.rec' => 'ヘッダー値を検証しサニタイズしてください。',
|
||||
'xss.content_tainted' => ':method() 内の汚染されたデータはXSSの脆弱性を引き起こす可能性があります。',
|
||||
'xss.content_tainted.rec' => 'レスポンスボディを設定する前にコンテンツをエスケープしてください。',
|
||||
|
||||
// SQL Injection messages
|
||||
'sqli.name' => 'SQLインジェクション',
|
||||
'sqli.db_raw' => 'ユーザー入力を含む DB::raw() はSQLインジェクションの脆弱性を引き起こす可能性があります。',
|
||||
'sqli.db_raw.rec' => 'パラメータ化クエリを使用してください: DB::raw("column = ?", [$value]) または、ユーザー入力での DB::raw() 使用を避けてください。',
|
||||
'sqli.db_query' => 'ユーザー入力を含む DB:::method() はSQLインジェクションの脆弱性を引き起こす可能性があります。',
|
||||
'sqli.db_query.rec' => 'パラメータバインディングを使用してください: DB::select("SELECT * FROM users WHERE id = ?", [$id])',
|
||||
'sqli.raw_method' => 'ユーザー入力を含む :method() はSQLインジェクションの脆弱性を引き起こす可能性があります。',
|
||||
'sqli.raw_method.rec' => 'バインディングを使用してください: ->:method(\'column = ?\', [$value])',
|
||||
'sqli.order_by_raw' => '動的なカラム名を含む :method() はSQLインジェクションの脆弱性があります。',
|
||||
'sqli.order_by_raw.rec' => 'カラム名にはホワイトリスト検証を使用してください: in_array($column, $allowedColumns)',
|
||||
'sqli.pdo_query' => 'ユーザー入力を含む PDO の query()/exec() はSQLインジェクションの脆弱性があります。',
|
||||
'sqli.pdo_query.rec' => 'プリペアドステートメントを使用してください: $stmt = $pdo->prepare($sql); $stmt->execute([$params]);',
|
||||
'sqli.pdo_prepare' => 'prepare() の前にユーザー入力を連結すると、プリペアドステートメントの意味がなくなります。',
|
||||
'sqli.pdo_prepare.rec' => 'プレースホルダーを使用してください: $pdo->prepare("SELECT * FROM users WHERE id = ?")',
|
||||
'sqli.mysqli' => 'ユーザー入力を含む mysqli->:method() はSQLインジェクションの脆弱性があります。',
|
||||
'sqli.mysqli.rec' => 'プリペアドステートメントを使用してください: $stmt = $mysqli->prepare($sql); $stmt->bind_param(...);',
|
||||
'sqli.func' => 'ユーザー入力を含む :func() はSQLインジェクションの脆弱性があります。',
|
||||
'sqli.func.rec' => '直接クエリ実行の代わりにプリペアドステートメントを使用してください。',
|
||||
'sqli.string_concat' => '文字列連結でSQLクエリを構築するとインジェクションの脆弱性を引き起こす可能性があります。',
|
||||
'sqli.string_concat.rec' => 'パラメータバインディングを持つプリペアドステートメントを使用してください。',
|
||||
'sqli.dynamic_column' => 'クエリ内の動的なカラム名はSQLインジェクションの脆弱性を引き起こす可能性があります。',
|
||||
'sqli.dynamic_column.rec' => 'クエリで使用する前に許可されたカラム名をホワイトリストで検証してください。',
|
||||
'sqli.sanitizer_broken' => 'SQLサニタイズが :func() によって無効化されています。',
|
||||
'sqli.sanitizer_broken.rec' => 'サニタイズ後に urldecode()、stripslashes() などの関数を使用しないでください。',
|
||||
|
||||
// Command Injection messages
|
||||
'cmdi.name' => 'コマンドインジェクション',
|
||||
'cmdi.shell_func' => 'ユーザー入力を含む :func() はコマンドインジェクションの脆弱性があります。',
|
||||
'cmdi.shell_func.rec' => 'ユーザー入力でのシェル関数の使用を避けてください。どうしても必要な場合は escapeshellarg() と escapeshellcmd() を使用するか、配列引数を持つ Process コンポーネントを使用してください。',
|
||||
'cmdi.shell_func_concat' => '連結されたユーザー入力を含む :func() はコマンドインジェクションの脆弱性がある可能性があります。',
|
||||
'cmdi.shell_func_concat.rec' => '引数には escapeshellarg()、コマンド全体には escapeshellcmd() を使用してください。',
|
||||
'cmdi.shell_func_review' => '動的なコマンドを持つ :func() はコマンドインジェクションの確認が必要です。',
|
||||
'cmdi.shell_func_review.rec' => 'コマンドと引数が適切にエスケープされていることを確認するか、配列引数を持つ Process コンポーネントを使用してください。',
|
||||
'cmdi.eval' => '動的なコードを含む eval() は非常に危険であり、コードインジェクションの脆弱性があります。',
|
||||
'cmdi.eval.rec' => 'eval() の使用を完全に避けてください。適切なデザインパターン、コールバック、またはテンプレートエンジンを使用してください。',
|
||||
'cmdi.create_function' => 'create_function() は非推奨であり、コードインジェクションの脆弱性があります。',
|
||||
'cmdi.create_function.rec' => '代わりに無名関数(クロージャ)を使用してください: function($args) { ... }',
|
||||
'cmdi.assert' => '文字列引数を持つ assert() はコードを評価し、インジェクションの脆弱性があります。',
|
||||
'cmdi.assert.rec' => 'ブール式のみで assert() を使用してください: assert($condition === true)',
|
||||
'cmdi.call_user_func' => 'ユーザー制御のコールバックを持つ :func() はコードインジェクションの脆弱性があります。',
|
||||
'cmdi.call_user_func.rec' => '許可されたコールバックをホワイトリストで管理するか、別のデザインパターンを使用してください。',
|
||||
'cmdi.preg_replace_e' => '/e 修飾子を持つ preg_replace() はコードを実行し、非推奨かつ危険です。',
|
||||
'cmdi.preg_replace_e.rec' => '/e 修飾子の代わりに preg_replace_callback() を使用してください。',
|
||||
'cmdi.file_inclusion' => 'ユーザー入力を含む :func() はローカル/リモートファイルインクルージョン (LFI/RFI) の脆弱性があります。',
|
||||
'cmdi.file_inclusion.rec' => 'インクルードパスにユーザー入力を使用しないでください。許可されたファイルのホワイトリストを使用してください。',
|
||||
'cmdi.file_inclusion_dynamic' => '動的なパスを持つ :func() はファイルインクルージョンの脆弱性がある可能性があります。',
|
||||
'cmdi.file_inclusion_dynamic.rec' => 'ファイルパスを検証しサニタイズしてください。ディレクトリトラバーサルを防ぐために basename() を使用してください。',
|
||||
'cmdi.backtick' => 'ユーザー入力を含むバッククォートでのシェル実行はコマンドインジェクションの脆弱性があります。',
|
||||
'cmdi.backtick.rec' => 'バッククォート実行を避けてください。適切な引数エスケープを持つ Process コンポーネントを使用してください。',
|
||||
'cmdi.backtick_review' => 'バッククォートでのシェル実行はコマンドインジェクションのリスクを確認する必要があります。',
|
||||
'cmdi.backtick_review.rec' => 'バッククォートの代わりに Process コンポーネントの使用を検討してください。',
|
||||
'cmdi.process_shell' => 'ユーザー入力を含む Process::fromShellCommandline() はコマンドインジェクションの脆弱性があります。',
|
||||
'cmdi.process_shell.rec' => '代わりに new Process([\'command\', \'arg1\', \'arg2\']) を配列引数で使用してください。',
|
||||
'cmdi.artisan_call' => 'ユーザー制御のコマンド名を持つ Artisan::call() は危険です。',
|
||||
'cmdi.artisan_call.rec' => '許可された Artisan コマンドをホワイトリストで管理してください。',
|
||||
'cmdi.process_tainted' => 'ユーザー入力を含む Process のインスタンス化はコマンドインジェクションの脆弱性がある可能性があります。',
|
||||
'cmdi.process_tainted.rec' => '文字列コマンドの代わりに配列引数を使用してください: new Process([\'command\', $arg])',
|
||||
'cmdi.process_args' => 'ユーザー制御の引数を持つ Process は慎重に検証する必要があります。',
|
||||
'cmdi.process_args.rec' => 'Process 引数として使用する前にユーザー入力を検証してください。',
|
||||
'cmdi.sanitizer_broken' => 'コマンドサニタイズが :func() によって無効化されています。',
|
||||
'cmdi.sanitizer_broken.rec' => 'escapeshellarg()/escapeshellcmd() の後に urldecode()、str_replace() などを使用しないでください。',
|
||||
|
||||
// Path Traversal messages
|
||||
'path.name' => 'パストラバーサル',
|
||||
'path.traversal' => 'ユーザー入力を含む :func() はパストラバーサル攻撃の脆弱性があります。',
|
||||
'path.traversal_potential' => '汚染された可能性のあるパスを含む :func() はパストラバーサルの脆弱性がある可能性があります。',
|
||||
'path.traversal.rec' => 'ファイル名には basename()、パスの解決には realpath() を使用し、許可されたディレクトリに対して検証してください。',
|
||||
'path.upload' => 'ユーザー制御の宛先パスを持つ move_uploaded_file() は任意のファイルアップロードを許可します。',
|
||||
'path.upload.rec' => '許可されたディレクトリのホワイトリストを使用してください。ファイル名はサーバー側で生成し、ユーザー入力から取得しないでください。',
|
||||
'path.storage' => 'ユーザー制御のパスを持つ :method() は許可されていないファイルへのアクセスを許可する可能性があります。',
|
||||
'path.storage.rec' => '許可されたパターンに対してパスを検証してください。ファイル名には basename() を使用してください。',
|
||||
'path.download' => 'ユーザー入力を含む response()->:method() は任意のファイルダウンロードを許可します。',
|
||||
'path.download.rec' => 'basename() を使用し、許可されたディレクトリのホワイトリストに対して検証してください。',
|
||||
'path.store' => 'ユーザー制御のパスを持つ :method() は意図しない場所へのファイル保存を許可する可能性があります。',
|
||||
'path.store.rec' => '固定の宛先パスを使用してください。ユーザー入力が必要な場合は、basename() を使用し、ディレクトリをホワイトリストで管理してください。',
|
||||
'path.store_filename' => 'ユーザー制御のファイル名を持つ :method() はパストラバーサルを許可する可能性があります。',
|
||||
'path.store_filename.rec' => 'ユーザー提供のファイル名には basename() を使用するか、サーバー側でファイル名を生成してください。',
|
||||
'path.sanitizer_broken' => 'パスサニタイズが :func() によって無効化されています。',
|
||||
'path.sanitizer_broken.rec' => 'basename()/realpath() の後に urldecode()、base64_decode() などを使用しないでください。',
|
||||
'path.dangerous_pattern' => 'パスに危険なトラバーサルパターン (:pattern) が含まれています。',
|
||||
'path.dangerous_pattern.rec' => 'パスから ../ などのトラバーサルシーケンスを削除するか、basename() を使用してください。',
|
||||
|
||||
// Authentication messages
|
||||
'auth.name' => '認証セキュリティ',
|
||||
'auth.weak_hash' => 'パスワードのハッシュ化に :func() を使用すべきではありません。レインボーテーブルやブルートフォース攻撃に対して脆弱です。',
|
||||
'auth.weak_hash.rec' => 'PASSWORD_ARGON2ID または PASSWORD_BCRYPT を指定した password_hash()、または Laravel の Hash::make() を使用してください。',
|
||||
'auth.weak_hash_review' => ':func() が使用されています。これがパスワードやセキュリティ上重要なデータに使用されていないことを確認してください。',
|
||||
'auth.weak_hash_review.rec' => 'パスワードには password_hash() を使用してください。セキュリティトークンには random_bytes() を使用してください。',
|
||||
'auth.weak_algo' => ':algo を指定した password_hash() は安全ではありません。',
|
||||
'auth.weak_algo.rec' => 'PASSWORD_ARGON2ID または PASSWORD_BCRYPT を使用してください。',
|
||||
'auth.low_cost' => 'パスワードハッシュのコストファクター :cost は低すぎます。推奨最小値は12です。',
|
||||
'auth.low_cost.rec' => 'bcrypt には少なくとも12のコストファクターを使用してください: [\'cost\' => 12]',
|
||||
'auth.low_rounds' => 'ハッシュラウンド数 :rounds は低すぎます。',
|
||||
'auth.low_rounds.rec' => 'bcrypt には少なくとも12ラウンドを使用してください。',
|
||||
'auth.encrypt_password' => 'パスワードにハッシュではなく暗号化を使用しています。暗号化されたパスワードは復号できます。',
|
||||
'auth.encrypt_password.rec' => 'パスワードには Crypt::encrypt() ではなく Hash::make() を使用してください。',
|
||||
'auth.hardcoded' => '変数 \':var\' にハードコードされた認証情報が見つかりました。',
|
||||
'auth.hardcoded.rec' => '認証情報は環境変数または安全なシークレットマネージャーに保存してください。',
|
||||
'auth.hardcoded_array' => '配列キー \':key\' にハードコードされた認証情報が見つかりました。',
|
||||
'auth.hardcoded_array.rec' => '環境変数を使用してください: env(\'KEY_NAME\') または config 値。',
|
||||
'auth.timing' => '認証情報/トークンの文字列比較はタイミング攻撃に対して脆弱な可能性があります。',
|
||||
'auth.timing.rec' => '定時間文字列比較には hash_equals() を、パスワードには password_verify() を使用してください。',
|
||||
'auth.base64' => ':func() は暗号化ではありません。パスワードはエンコードではなくハッシュ化する必要があります。',
|
||||
'auth.base64.rec' => 'パスワードには base64_encode() ではなく password_hash() を使用してください。',
|
||||
'auth.strcmp' => '認証情報の比較に :func() を使用するとタイミング攻撃に対して脆弱な可能性があります。',
|
||||
'auth.strcmp.rec' => '定時間比較には hash_equals() を使用してください。',
|
||||
|
||||
// CSRF/Session messages
|
||||
'csrf.name' => 'CSRF/セッションセキュリティ',
|
||||
'csrf.missing_token' => '状態変更メソッドを持つフォームにCSRF保護がありません。',
|
||||
'csrf.missing_token.rec' => 'フォーム内に @csrf ディレクティブを追加するか、csrf_field() を使用してください。',
|
||||
'csrf.missing_method' => 'PUT/PATCH/DELETE に @method ディレクティブが必要なようです。',
|
||||
'csrf.missing_method.rec' => 'メソッドスプーフィングには @method(\'PUT\') ディレクティブを使用してください。',
|
||||
'csrf.ajax_no_token' => 'CSRFトークン設定なしのAJAXリクエストが検出されました。',
|
||||
'csrf.ajax_no_token.rec' => 'AJAXリクエストにはメタタグまたはCookieから X-CSRF-TOKEN ヘッダーを設定してください。',
|
||||
'csrf.disabled' => 'このルートでCSRFミドルウェアが無効化されています。',
|
||||
'csrf.disabled.rec' => '代替認証を持つWebhook/APIでのみCSRFを無効にしてください。',
|
||||
'session.no_options' => 'session_start() が明示的なセキュリティ設定なしで呼び出されています。',
|
||||
'session.no_options.rec' => 'セキュアなオプションでセッションを設定してください: cookie_httponly, cookie_secure, cookie_samesite。',
|
||||
'session.no_httponly' => 'セッションCookieに HttpOnly フラグがないため、XSSに対して脆弱です。',
|
||||
'session.no_httponly.rec' => 'session_start() オプションで \'cookie_httponly\' => true を設定してください。',
|
||||
'session.no_secure' => 'セッションCookieがHTTP経由で送信される可能性があり、傍受に対して脆弱です。',
|
||||
'session.no_secure.rec' => 'HTTPS環境では \'cookie_secure\' => true を設定してください。',
|
||||
'session.no_samesite' => 'セッションCookieに SameSite 属性がないため、CSRFに対して脆弱です。',
|
||||
'session.no_samesite.rec' => '\'cookie_samesite\' => \'Strict\' または \'Lax\' を設定してください。',
|
||||
'session.fixation' => '古いセッションを削除せずに session_regenerate_id() が呼び出されています。',
|
||||
'session.fixation.rec' => '古いセッションを削除するために session_regenerate_id(true) を使用してください。',
|
||||
'session.fixation_false' => 'session_regenerate_id(false) は古いセッションデータを保持します。',
|
||||
'session.fixation_false.rec' => 'セキュリティのために session_regenerate_id(true) を使用してください。',
|
||||
'session.insecure_ini' => 'ini_set(\':setting\', \':value\') はセッションセキュリティを弱めます。',
|
||||
'session.insecure_ini.rec' => ':setting を安全な値(1 または true)に設定してください。',
|
||||
'cookie.no_httponly' => 'Cookie に HttpOnly フラグがないため、JavaScript からアクセス可能です。',
|
||||
'cookie.no_httponly.rec' => 'Cookie オプションに \'httponly\' => true を追加してください。',
|
||||
'cookie.no_secure' => 'Cookie が安全でないHTTP接続経由で送信される可能性があります。',
|
||||
'cookie.no_secure.rec' => 'HTTPS環境では \'secure\' => true を追加してください。',
|
||||
'cookie.no_samesite' => 'Cookie に SameSite 属性がないため、CSRFに対して脆弱です。',
|
||||
'cookie.no_samesite.rec' => '\'samesite\' => \'Strict\' または \'Lax\' を追加してください。',
|
||||
'session.sensitive_data' => '潜在的に機密性の高いデータ \':key\' をセッションに保存しています。',
|
||||
'session.sensitive_data.rec' => 'セッションに機密データを保存しないでください。暗号化されたストレージまたは参照IDを使用してください。',
|
||||
|
||||
// Insecure Config messages
|
||||
'config.name' => '設定セキュリティ',
|
||||
'config.phpinfo' => 'phpinfo() は機密性の高いサーバー設定を公開します。',
|
||||
'config.phpinfo.rec' => '本番コードから phpinfo() を削除してください。',
|
||||
'config.debug_output' => ':func() は本番環境で機密情報を公開する可能性があります。',
|
||||
'config.debug_output.rec' => 'デバッグ出力を削除するか、適切なログ記録を使用してください。',
|
||||
'config.error_reporting' => 'error_reporting(-1) はすべてのエラーを表示し、本番環境ではリスクがあります。',
|
||||
'config.error_reporting.rec' => '本番環境では error_reporting(0) を使用し、エラーはファイルに記録してください。',
|
||||
'config.insecure_ini' => 'ini_set(\':setting\', \':value\') は本番環境では安全ではありません。',
|
||||
'config.insecure_ini.rec' => '本番環境では :setting を無効にしてください。',
|
||||
'config.header_powered_by' => 'X-Powered-By ヘッダーはサーバー技術を公開します。',
|
||||
'config.header_powered_by.rec' => 'X-Powered-By ヘッダーを削除するか、サーバー設定で非表示にしてください。',
|
||||
'config.header_server' => 'Server ヘッダーはサーバーソフトウェアを公開します。',
|
||||
'config.header_server.rec' => 'バージョン情報を隠すようにサーバーを設定してください。',
|
||||
'config.unserialize' => 'allowed_classes オプションのない unserialize() はオブジェクトインジェクションにつながる可能性があります。',
|
||||
'config.unserialize.rec' => 'unserialize($data, [\'allowed_classes\' => false]) を使用するか、許可されるクラスを指定してください。',
|
||||
'config.unserialize_true' => '\'allowed_classes\' => true を指定した unserialize() はすべてのクラスを許可します。',
|
||||
'config.unserialize_true.rec' => '\'allowed_classes\' を false または特定のクラスの配列に設定してください。',
|
||||
'config.unserialize_no_key' => 'unserialize() オプションに \'allowed_classes\' キーがありません。',
|
||||
'config.unserialize_no_key.rec' => 'オプション配列に \'allowed_classes\' => false を追加してください。',
|
||||
'config.debug_mode' => 'Config::set() でデバッグモードが有効化されています。',
|
||||
'config.debug_mode.rec' => '本番環境ではデバッグモードを無効にしてください。',
|
||||
'config.sensitive_log' => '潜在的に機密性の高いデータ \':var\' がログに記録される可能性があります。',
|
||||
'config.sensitive_log.rec' => 'パスワード、トークン、その他の機密データをログに記録しないでください。',
|
||||
'config.sensitive_log_key' => '機密キー \':key\' がログに記録される可能性があります。',
|
||||
'config.sensitive_log_key.rec' => 'ログから機密データをマスクするか除外してください。',
|
||||
'config.dd_dump' => ':func() が見つかりました - デバッグヘルパーは本番環境にあるべきではありません。',
|
||||
'config.dd_dump.rec' => 'デプロイ前にデバッグヘルパーを削除してください。',
|
||||
'config.debug_hardcoded' => '設定でデバッグモードが true にハードコードされています。',
|
||||
'config.debug_hardcoded.rec' => 'デバッグ設定には env(\'APP_DEBUG\', false) を使用してください。',
|
||||
'config.hardcoded_secret' => 'シークレット \':key\' が設定ファイルにハードコードされています。',
|
||||
'config.hardcoded_secret.rec' => '環境から読み込むために env(\':key\') を使用してください。',
|
||||
'config.env_no_default' => '変数が存在しない場合に問題を引き起こす可能性があるデフォルト値なしの env() が呼び出されています。',
|
||||
'config.env_no_default.rec' => 'デフォルト値を指定してください: env(\'KEY\', \'default\')',
|
||||
|
||||
// Report messages
|
||||
'report.title' => 'セキュリティスキャン結果',
|
||||
'report.generated' => '生成日時',
|
||||
'report.no_issues' => '脆弱性は見つかりませんでした。',
|
||||
],
|
||||
|
||||
'en' => [
|
||||
// CLI messages
|
||||
'cli.banner' => 'PHP/Laravel Security Linter',
|
||||
'cli.analyzing' => 'Analyzing: :path',
|
||||
'cli.completed' => 'Completed in :time s',
|
||||
'cli.no_vulnerabilities' => 'No security vulnerabilities found!',
|
||||
'cli.report_written' => 'Report written to: :path',
|
||||
'cli.summary' => 'Summary',
|
||||
'cli.total' => 'Total',
|
||||
'cli.critical' => 'Critical',
|
||||
'cli.high' => 'High',
|
||||
'cli.medium' => 'Medium',
|
||||
'cli.low' => 'Low',
|
||||
'cli.file' => 'File',
|
||||
'cli.line' => 'Line',
|
||||
'cli.recommendation' => 'Recommendation',
|
||||
'cli.call_trace' => 'Call Trace',
|
||||
|
||||
// Severity labels
|
||||
'severity.critical' => 'Critical',
|
||||
'severity.high' => 'High',
|
||||
'severity.medium' => 'Medium',
|
||||
'severity.low' => 'Low',
|
||||
|
||||
// XSS messages
|
||||
'xss.name' => 'XSS (Cross-Site Scripting)',
|
||||
'xss.unescaped_echo' => 'Unescaped output may lead to XSS. User input is echoed without proper escaping.',
|
||||
'xss.unescaped_echo.rec' => 'Use htmlspecialchars($var, ENT_QUOTES, \'UTF-8\') or Laravel\'s e() helper.',
|
||||
'xss.unescaped_print' => 'Unescaped print may lead to XSS.',
|
||||
'xss.unescaped_print.rec' => 'Use htmlspecialchars() to escape output.',
|
||||
'xss.blade_raw' => 'Raw Blade output {!! !!} may lead to XSS. Expression: :expr',
|
||||
'xss.blade_raw.rec' => 'Use {{ }} for automatic escaping, or ensure content is sanitized before raw output.',
|
||||
'xss.blade_php_echo' => 'Echo in @php block without escaping may lead to XSS.',
|
||||
'xss.blade_php_echo.rec' => 'Use Blade {{ }} syntax or escape with htmlspecialchars().',
|
||||
'xss.blade_js_context' => 'Blade output in JavaScript context may lead to XSS even with escaping.',
|
||||
'xss.blade_js_context.rec' => 'Use @json() directive or JSON.parse() with proper encoding for JS context.',
|
||||
'xss.blade_js_raw' => 'Raw Blade output {!! !!} in JavaScript context is a critical XSS vulnerability. Expression: :expr',
|
||||
'xss.blade_js_raw.rec' => 'Never use {!! !!} in JavaScript. Use @json() or Js::from() instead.',
|
||||
'xss.url_context' => 'Blade output in URL context (:attr attribute) may lead to XSS via javascript: URLs. Expression: :expr',
|
||||
'xss.url_context.rec' => 'Validate URLs to exclude javascript:, data:, vbscript: protocols, or use route() and url() helpers.',
|
||||
'xss.event_handler' => 'Blade output in event handler attribute may lead to XSS. Expression: :expr',
|
||||
'xss.event_handler.rec' => 'Avoid passing dynamic values to event handlers. Use data attributes and JavaScript event listeners instead.',
|
||||
'xss.event_handler_raw' => 'Raw Blade output {!! !!} in event handler attribute is a critical XSS vulnerability. Expression: :expr',
|
||||
'xss.event_handler_raw.rec' => 'Never use {!! !!} in event handler attributes.',
|
||||
'xss.style_injection' => 'Blade output in style attribute may lead to CSS injection. Expression: :expr',
|
||||
'xss.style_injection.rec' => 'Validate CSS values and use a whitelist of allowed values.',
|
||||
'xss.unquoted_attr' => 'Blade output in unquoted attribute value (:attr) may break attribute escaping. Expression: :expr',
|
||||
'xss.unquoted_attr.rec' => 'Always quote attribute values: :attr="{{ $value }}"',
|
||||
'xss.json_in_script' => '@json() directive in script tag may have XSS risk. Expression: :expr',
|
||||
'xss.json_in_script.rec' => 'Consider using Js::from() for JavaScript context. Ensure JSON data is properly escaped.',
|
||||
'xss.template_injection' => '@:directive directive with variable $:var may lead to template injection.',
|
||||
'xss.template_injection.rec' => 'Never use user input in @include, @extends, @component. Use a whitelist of allowed templates.',
|
||||
'xss.svg_context' => 'Blade output in SVG context may lead to XSS. Expression: :expr',
|
||||
'xss.svg_context.rec' => 'SVG can contain script tags and event handlers. Sanitize user data in SVG carefully.',
|
||||
'xss.printf_tainted' => 'Printf with tainted argument may lead to XSS.',
|
||||
'xss.printf_tainted.rec' => 'Escape user input before using in printf: htmlspecialchars($input)',
|
||||
'xss.response_tainted' => 'Response with tainted data may lead to XSS.',
|
||||
'xss.response_tainted.rec' => 'Ensure data passed to views is properly escaped.',
|
||||
'xss.header_tainted' => 'Tainted data in HTTP header may lead to header injection.',
|
||||
'xss.header_tainted.rec' => 'Validate and sanitize header values.',
|
||||
'xss.content_tainted' => 'Tainted data in :method() may lead to XSS.',
|
||||
'xss.content_tainted.rec' => 'Escape content before setting response body.',
|
||||
|
||||
// SQL Injection messages
|
||||
'sqli.name' => 'SQL Injection',
|
||||
'sqli.db_raw' => 'DB::raw() with user input may lead to SQL injection.',
|
||||
'sqli.db_raw.rec' => 'Use parameterized queries: DB::raw("column = ?", [$value]) or avoid DB::raw() with user input.',
|
||||
'sqli.db_query' => 'DB:::method() with user input may lead to SQL injection.',
|
||||
'sqli.db_query.rec' => 'Use parameter bindings: DB::select("SELECT * FROM users WHERE id = ?", [$id])',
|
||||
'sqli.raw_method' => ':method() with user input may lead to SQL injection.',
|
||||
'sqli.raw_method.rec' => 'Use bindings: ->:method(\'column = ?\', [$value])',
|
||||
'sqli.order_by_raw' => ':method() with dynamic column names is vulnerable to SQL injection.',
|
||||
'sqli.order_by_raw.rec' => 'Use whitelist validation for column names: in_array($column, $allowedColumns)',
|
||||
'sqli.pdo_query' => 'PDO query()/exec() with user input is vulnerable to SQL injection.',
|
||||
'sqli.pdo_query.rec' => 'Use prepared statements: $stmt = $pdo->prepare($sql); $stmt->execute([$params]);',
|
||||
'sqli.pdo_prepare' => 'Concatenating user input before prepare() defeats prepared statements.',
|
||||
'sqli.pdo_prepare.rec' => 'Use placeholders: $pdo->prepare("SELECT * FROM users WHERE id = ?")',
|
||||
'sqli.mysqli' => 'mysqli->:method() with user input is vulnerable to SQL injection.',
|
||||
'sqli.mysqli.rec' => 'Use prepared statements: $stmt = $mysqli->prepare($sql); $stmt->bind_param(...);',
|
||||
'sqli.func' => ':func() with user input is vulnerable to SQL injection.',
|
||||
'sqli.func.rec' => 'Use prepared statements instead of direct query execution.',
|
||||
'sqli.string_concat' => 'Building SQL query with string concatenation may lead to injection.',
|
||||
'sqli.string_concat.rec' => 'Use prepared statements with parameter binding.',
|
||||
'sqli.dynamic_column' => 'Dynamic column names in query may lead to SQL injection.',
|
||||
'sqli.dynamic_column.rec' => 'Whitelist allowed column names before using in queries.',
|
||||
'sqli.sanitizer_broken' => 'SQL sanitization is broken by :func().',
|
||||
'sqli.sanitizer_broken.rec' => 'Do not use urldecode(), stripslashes(), etc. after sanitization.',
|
||||
|
||||
// Command Injection messages
|
||||
'cmdi.name' => 'Command Injection',
|
||||
'cmdi.shell_func' => ':func() with user input is vulnerable to command injection.',
|
||||
'cmdi.shell_func.rec' => 'Avoid shell functions with user input. Use escapeshellarg() and escapeshellcmd() if unavoidable, or use Process component with array arguments.',
|
||||
'cmdi.shell_func_concat' => ':func() with concatenated user input may be vulnerable to command injection.',
|
||||
'cmdi.shell_func_concat.rec' => 'Use escapeshellarg() for arguments and escapeshellcmd() for the entire command.',
|
||||
'cmdi.shell_func_review' => ':func() with dynamic command should be reviewed for command injection.',
|
||||
'cmdi.shell_func_review.rec' => 'Ensure command and arguments are properly escaped or use Process component with array arguments.',
|
||||
'cmdi.eval' => 'eval() with dynamic code is extremely dangerous and vulnerable to code injection.',
|
||||
'cmdi.eval.rec' => 'Avoid eval() entirely. Use proper design patterns, callbacks, or template engines instead.',
|
||||
'cmdi.create_function' => 'create_function() is deprecated and vulnerable to code injection.',
|
||||
'cmdi.create_function.rec' => 'Use anonymous functions (closures) instead: function($args) { ... }',
|
||||
'cmdi.assert' => 'assert() with string argument evaluates code and is vulnerable to injection.',
|
||||
'cmdi.assert.rec' => 'Use assert() with boolean expressions only: assert($condition === true)',
|
||||
'cmdi.call_user_func' => ':func() with user-controlled callback is vulnerable to code injection.',
|
||||
'cmdi.call_user_func.rec' => 'Whitelist allowed callbacks or use a different design pattern.',
|
||||
'cmdi.preg_replace_e' => 'preg_replace() with /e modifier executes code and is deprecated/dangerous.',
|
||||
'cmdi.preg_replace_e.rec' => 'Use preg_replace_callback() instead of the /e modifier.',
|
||||
'cmdi.file_inclusion' => ':func() with user input is vulnerable to Local/Remote File Inclusion (LFI/RFI).',
|
||||
'cmdi.file_inclusion.rec' => 'Never use user input in include paths. Use a whitelist of allowed files.',
|
||||
'cmdi.file_inclusion_dynamic' => ':func() with dynamic path may be vulnerable to file inclusion.',
|
||||
'cmdi.file_inclusion_dynamic.rec' => 'Validate and sanitize file paths. Use basename() to prevent directory traversal.',
|
||||
'cmdi.backtick' => 'Backtick shell execution with user input is vulnerable to command injection.',
|
||||
'cmdi.backtick.rec' => 'Avoid backtick execution. Use Process component with proper argument escaping.',
|
||||
'cmdi.backtick_review' => 'Backtick shell execution should be reviewed for command injection risks.',
|
||||
'cmdi.backtick_review.rec' => 'Consider using Process component instead of backticks.',
|
||||
'cmdi.process_shell' => 'Process::fromShellCommandline() with user input is vulnerable to command injection.',
|
||||
'cmdi.process_shell.rec' => 'Use new Process([\'command\', \'arg1\', \'arg2\']) with array arguments instead.',
|
||||
'cmdi.artisan_call' => 'Artisan::call() with user-controlled command name is dangerous.',
|
||||
'cmdi.artisan_call.rec' => 'Whitelist allowed Artisan commands.',
|
||||
'cmdi.process_tainted' => 'Process instantiation with user input may be vulnerable to command injection.',
|
||||
'cmdi.process_tainted.rec' => 'Use array arguments: new Process([\'command\', $arg]) instead of string commands.',
|
||||
'cmdi.process_args' => 'Process with user-controlled arguments should be carefully validated.',
|
||||
'cmdi.process_args.rec' => 'Validate user input before using as Process arguments.',
|
||||
'cmdi.sanitizer_broken' => 'Command sanitization is broken by :func().',
|
||||
'cmdi.sanitizer_broken.rec' => 'Do not use urldecode(), str_replace(), etc. after escapeshellarg()/escapeshellcmd().',
|
||||
|
||||
// Path Traversal messages
|
||||
'path.name' => 'Path Traversal',
|
||||
'path.traversal' => ':func() with user input is vulnerable to path traversal attacks.',
|
||||
'path.traversal_potential' => ':func() with potentially tainted path may be vulnerable to path traversal.',
|
||||
'path.traversal.rec' => 'Use basename() for filenames, realpath() to resolve paths, and validate against allowed directories.',
|
||||
'path.upload' => 'move_uploaded_file() with user-controlled destination path allows arbitrary file upload.',
|
||||
'path.upload.rec' => 'Use a whitelist for allowed directories. Generate filenames server-side, never from user input.',
|
||||
'path.storage' => ':method() with user-controlled path may allow access to unauthorized files.',
|
||||
'path.storage.rec' => 'Validate path against allowed patterns. Use basename() for filenames.',
|
||||
'path.download' => 'response()->:method() with user input allows arbitrary file download.',
|
||||
'path.download.rec' => 'Use basename() and validate against a whitelist of allowed directories.',
|
||||
'path.store' => ':method() with user-controlled path may allow storing files in unintended locations.',
|
||||
'path.store.rec' => 'Use a fixed destination path. If user input is needed, use basename() and whitelist directories.',
|
||||
'path.store_filename' => ':method() with user-controlled filename may allow path traversal.',
|
||||
'path.store_filename.rec' => 'Use basename() on user-provided filenames or generate filenames server-side.',
|
||||
'path.sanitizer_broken' => 'Path sanitization is broken by :func().',
|
||||
'path.sanitizer_broken.rec' => 'Do not use urldecode(), base64_decode(), etc. after basename()/realpath().',
|
||||
'path.dangerous_pattern' => 'Path contains dangerous traversal pattern (:pattern).',
|
||||
'path.dangerous_pattern.rec' => 'Remove traversal sequences like ../ from paths or use basename().',
|
||||
|
||||
// Authentication messages
|
||||
'auth.name' => 'Authentication Security',
|
||||
'auth.weak_hash' => ':func() should not be used for password hashing. It is vulnerable to rainbow table and brute force attacks.',
|
||||
'auth.weak_hash.rec' => 'Use password_hash() with PASSWORD_ARGON2ID or PASSWORD_BCRYPT, or Laravel Hash::make().',
|
||||
'auth.weak_hash_review' => ':func() is used. Ensure this is not for password or security-sensitive data.',
|
||||
'auth.weak_hash_review.rec' => 'For passwords, use password_hash(). For security tokens, use random_bytes().',
|
||||
'auth.weak_algo' => 'password_hash() with :algo is insecure.',
|
||||
'auth.weak_algo.rec' => 'Use PASSWORD_ARGON2ID or PASSWORD_BCRYPT.',
|
||||
'auth.low_cost' => 'Password hash cost factor :cost is too low. Minimum recommended is 12.',
|
||||
'auth.low_cost.rec' => 'Use cost factor of at least 12 for bcrypt: [\'cost\' => 12]',
|
||||
'auth.low_rounds' => 'Hash rounds :rounds is too low.',
|
||||
'auth.low_rounds.rec' => 'Use at least 12 rounds for bcrypt.',
|
||||
'auth.encrypt_password' => 'Using encryption for passwords instead of hashing. Encrypted passwords can be decrypted.',
|
||||
'auth.encrypt_password.rec' => 'Use Hash::make() for passwords, not Crypt::encrypt().',
|
||||
'auth.hardcoded' => 'Hardcoded credential found in variable \':var\'.',
|
||||
'auth.hardcoded.rec' => 'Store credentials in environment variables or a secure secrets manager.',
|
||||
'auth.hardcoded_array' => 'Hardcoded credential found in array key \':key\'.',
|
||||
'auth.hardcoded_array.rec' => 'Use environment variables: env(\'KEY_NAME\') or config values.',
|
||||
'auth.timing' => 'String comparison of credentials/tokens may be vulnerable to timing attacks.',
|
||||
'auth.timing.rec' => 'Use hash_equals() for constant-time string comparison, or password_verify() for passwords.',
|
||||
'auth.base64' => ':func() is not encryption. Passwords should be hashed, not encoded.',
|
||||
'auth.base64.rec' => 'Use password_hash() for passwords, not base64_encode().',
|
||||
'auth.strcmp' => ':func() for credential comparison may be vulnerable to timing attacks.',
|
||||
'auth.strcmp.rec' => 'Use hash_equals() for constant-time comparison.',
|
||||
|
||||
// CSRF/Session messages
|
||||
'csrf.name' => 'CSRF/Session Security',
|
||||
'csrf.missing_token' => 'Form with state-changing method is missing CSRF protection.',
|
||||
'csrf.missing_token.rec' => 'Add @csrf directive inside the form or use csrf_field().',
|
||||
'csrf.missing_method' => 'Form appears to need @method directive for PUT/PATCH/DELETE.',
|
||||
'csrf.missing_method.rec' => 'Use @method(\'PUT\') directive for method spoofing.',
|
||||
'csrf.ajax_no_token' => 'AJAX request detected without CSRF token setup.',
|
||||
'csrf.ajax_no_token.rec' => 'Set X-CSRF-TOKEN header from meta tag or cookie for AJAX requests.',
|
||||
'csrf.disabled' => 'CSRF middleware disabled for this route.',
|
||||
'csrf.disabled.rec' => 'Only disable CSRF for webhooks/APIs with alternative authentication.',
|
||||
'session.no_options' => 'session_start() called without explicit security settings.',
|
||||
'session.no_options.rec' => 'Configure session with secure options: cookie_httponly, cookie_secure, cookie_samesite.',
|
||||
'session.no_httponly' => 'Session cookie missing HttpOnly flag, vulnerable to XSS.',
|
||||
'session.no_httponly.rec' => 'Set \'cookie_httponly\' => true in session_start() options.',
|
||||
'session.no_secure' => 'Session cookie may be sent over HTTP, vulnerable to interception.',
|
||||
'session.no_secure.rec' => 'Set \'cookie_secure\' => true for HTTPS environments.',
|
||||
'session.no_samesite' => 'Session cookie missing SameSite attribute, vulnerable to CSRF.',
|
||||
'session.no_samesite.rec' => 'Set \'cookie_samesite\' => \'Strict\' or \'Lax\'.',
|
||||
'session.fixation' => 'session_regenerate_id() called without deleting old session.',
|
||||
'session.fixation.rec' => 'Use session_regenerate_id(true) to delete the old session.',
|
||||
'session.fixation_false' => 'session_regenerate_id(false) keeps old session data.',
|
||||
'session.fixation_false.rec' => 'Use session_regenerate_id(true) for security.',
|
||||
'session.insecure_ini' => 'ini_set(\':setting\', \':value\') weakens session security.',
|
||||
'session.insecure_ini.rec' => 'Set :setting to a secure value (1 or true).',
|
||||
'cookie.no_httponly' => 'Cookie missing HttpOnly flag, accessible via JavaScript.',
|
||||
'cookie.no_httponly.rec' => 'Add \'httponly\' => true to cookie options.',
|
||||
'cookie.no_secure' => 'Cookie may be sent over insecure HTTP connection.',
|
||||
'cookie.no_secure.rec' => 'Add \'secure\' => true for HTTPS environments.',
|
||||
'cookie.no_samesite' => 'Cookie missing SameSite attribute, vulnerable to CSRF.',
|
||||
'cookie.no_samesite.rec' => 'Add \'samesite\' => \'Strict\' or \'Lax\'.',
|
||||
'session.sensitive_data' => 'Storing potentially sensitive data \':key\' in session.',
|
||||
'session.sensitive_data.rec' => 'Avoid storing sensitive data in sessions. Use encrypted storage or reference IDs.',
|
||||
|
||||
// Insecure Config messages
|
||||
'config.name' => 'Insecure Configuration',
|
||||
'config.phpinfo' => 'phpinfo() exposes sensitive server configuration.',
|
||||
'config.phpinfo.rec' => 'Remove phpinfo() from production code.',
|
||||
'config.debug_output' => ':func() may expose sensitive information in production.',
|
||||
'config.debug_output.rec' => 'Remove debug output or use proper logging.',
|
||||
'config.error_reporting' => 'error_reporting(-1) shows all errors, risky in production.',
|
||||
'config.error_reporting.rec' => 'Use error_reporting(0) and log errors to file in production.',
|
||||
'config.insecure_ini' => 'ini_set(\':setting\', \':value\') is insecure for production.',
|
||||
'config.insecure_ini.rec' => 'Disable :setting in production.',
|
||||
'config.header_powered_by' => 'X-Powered-By header exposes server technology.',
|
||||
'config.header_powered_by.rec' => 'Remove X-Powered-By header or hide it in server config.',
|
||||
'config.header_server' => 'Server header exposes server software.',
|
||||
'config.header_server.rec' => 'Configure server to hide version information.',
|
||||
'config.unserialize' => 'unserialize() without allowed_classes option may lead to object injection.',
|
||||
'config.unserialize.rec' => 'Use unserialize($data, [\'allowed_classes\' => false]) or specify allowed classes.',
|
||||
'config.unserialize_true' => 'unserialize() with \'allowed_classes\' => true allows all classes.',
|
||||
'config.unserialize_true.rec' => 'Set \'allowed_classes\' to false or an array of specific classes.',
|
||||
'config.unserialize_no_key' => 'unserialize() options missing \'allowed_classes\' key.',
|
||||
'config.unserialize_no_key.rec' => 'Add \'allowed_classes\' => false to options array.',
|
||||
'config.debug_mode' => 'Debug mode enabled via Config::set().',
|
||||
'config.debug_mode.rec' => 'Disable debug mode in production.',
|
||||
'config.sensitive_log' => 'Potentially sensitive data \':var\' may be logged.',
|
||||
'config.sensitive_log.rec' => 'Never log passwords, tokens, or other sensitive data.',
|
||||
'config.sensitive_log_key' => 'Sensitive key \':key\' may be logged.',
|
||||
'config.sensitive_log_key.rec' => 'Mask or exclude sensitive data from logs.',
|
||||
'config.dd_dump' => ':func() found - debug helper should not be in production.',
|
||||
'config.dd_dump.rec' => 'Remove debug helpers before deployment.',
|
||||
'config.debug_hardcoded' => 'Debug mode is hardcoded to true in config.',
|
||||
'config.debug_hardcoded.rec' => 'Use env(\'APP_DEBUG\', false) for debug settings.',
|
||||
'config.hardcoded_secret' => 'Secret \':key\' is hardcoded in config file.',
|
||||
'config.hardcoded_secret.rec' => 'Use env(\':key\') to load from environment.',
|
||||
'config.env_no_default' => 'env() called without default value may cause issues if variable is missing.',
|
||||
'config.env_no_default.rec' => 'Provide a default value: env(\'KEY\', \'default\')',
|
||||
|
||||
// Report messages
|
||||
'report.title' => 'Security Scan Results',
|
||||
'report.generated' => 'Generated',
|
||||
'report.no_issues' => 'No vulnerabilities found.',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
411
src/Report/ReportGenerator.php
Normal file
411
src/Report/ReportGenerator.php
Normal file
@@ -0,0 +1,411 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Report;
|
||||
|
||||
/**
|
||||
* Generates security reports in various formats
|
||||
*/
|
||||
class ReportGenerator
|
||||
{
|
||||
/**
|
||||
* Generate report in specified format
|
||||
*/
|
||||
public function generate(array $vulnerabilities, string $format = 'text'): string
|
||||
{
|
||||
return match ($format) {
|
||||
'json' => $this->generateJson($vulnerabilities),
|
||||
'html' => $this->generateHtml($vulnerabilities),
|
||||
'sarif' => $this->generateSarif($vulnerabilities),
|
||||
'markdown' => $this->generateMarkdown($vulnerabilities),
|
||||
default => $this->generateText($vulnerabilities),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate plain text report
|
||||
*/
|
||||
private function generateText(array $vulnerabilities): string
|
||||
{
|
||||
if (empty($vulnerabilities)) {
|
||||
return "No security vulnerabilities found.\n";
|
||||
}
|
||||
|
||||
$output = "Security Scan Results\n";
|
||||
$output .= str_repeat("=", 60) . "\n\n";
|
||||
|
||||
// Group by severity
|
||||
$grouped = $this->groupBySeverity($vulnerabilities);
|
||||
|
||||
foreach (['critical', 'high', 'medium', 'low'] as $severity) {
|
||||
if (empty($grouped[$severity])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = count($grouped[$severity]);
|
||||
$output .= strtoupper($severity) . " ({$count})\n";
|
||||
$output .= str_repeat("-", 40) . "\n";
|
||||
|
||||
foreach ($grouped[$severity] as $vuln) {
|
||||
$output .= $this->formatVulnerabilityText($vuln);
|
||||
$output .= "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
$output .= str_repeat("=", 60) . "\n";
|
||||
$output .= "Summary:\n";
|
||||
$output .= " Critical: " . count($grouped['critical'] ?? []) . "\n";
|
||||
$output .= " High: " . count($grouped['high'] ?? []) . "\n";
|
||||
$output .= " Medium: " . count($grouped['medium'] ?? []) . "\n";
|
||||
$output .= " Low: " . count($grouped['low'] ?? []) . "\n";
|
||||
$output .= " Total: " . count($vulnerabilities) . "\n";
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single vulnerability for text output
|
||||
*/
|
||||
private function formatVulnerabilityText(Vulnerability $vuln): string
|
||||
{
|
||||
$output = "\n[{$vuln->getType()}] {$vuln->getMessage()}\n";
|
||||
$output .= " File: {$vuln->getFile()}:{$vuln->getLine()}\n";
|
||||
|
||||
if ($vuln->getCode()) {
|
||||
$output .= " Code: {$vuln->getCode()}\n";
|
||||
}
|
||||
|
||||
if ($vuln->getCweId()) {
|
||||
$output .= " CWE: {$vuln->getCweId()}\n";
|
||||
}
|
||||
|
||||
if ($vuln->getOwaspCategory()) {
|
||||
$output .= " OWASP: {$vuln->getOwaspCategory()}\n";
|
||||
}
|
||||
|
||||
if (!empty($vuln->getCallTrace())) {
|
||||
$output .= " Call Trace:\n";
|
||||
foreach ($vuln->getCallTrace() as $trace) {
|
||||
$output .= " -> {$trace['function']} at {$trace['file']}:{$trace['line']}\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($vuln->getRecommendation()) {
|
||||
$output .= " Recommendation: {$vuln->getRecommendation()}\n";
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON report
|
||||
*/
|
||||
private function generateJson(array $vulnerabilities): string
|
||||
{
|
||||
$data = [
|
||||
'timestamp' => date('c'),
|
||||
'total' => count($vulnerabilities),
|
||||
'summary' => $this->getSummary($vulnerabilities),
|
||||
'vulnerabilities' => array_map(fn($v) => $v->toArray(), $vulnerabilities),
|
||||
];
|
||||
|
||||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML report
|
||||
*/
|
||||
private function generateHtml(array $vulnerabilities): string
|
||||
{
|
||||
$grouped = $this->groupBySeverity($vulnerabilities);
|
||||
$summary = $this->getSummary($vulnerabilities);
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Security Scan Report</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: white; padding: 30px; border-radius: 10px; margin-bottom: 20px; }
|
||||
header h1 { font-size: 24px; margin-bottom: 10px; }
|
||||
.summary { display: flex; gap: 15px; flex-wrap: wrap; }
|
||||
.summary-card { background: rgba(255,255,255,0.1); padding: 15px 25px; border-radius: 8px; text-align: center; }
|
||||
.summary-card .count { font-size: 32px; font-weight: bold; }
|
||||
.summary-card .label { font-size: 12px; text-transform: uppercase; opacity: 0.8; }
|
||||
.critical .count { color: #ff4757; }
|
||||
.high .count { color: #ff6b6b; }
|
||||
.medium .count { color: #ffa502; }
|
||||
.low .count { color: #7bed9f; }
|
||||
.section { background: white; border-radius: 10px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
.section-title { font-size: 18px; font-weight: 600; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #eee; }
|
||||
.vuln { background: #f8f9fa; border-left: 4px solid #ccc; padding: 15px; margin-bottom: 15px; border-radius: 0 8px 8px 0; }
|
||||
.vuln.critical { border-left-color: #ff4757; }
|
||||
.vuln.high { border-left-color: #ff6b6b; }
|
||||
.vuln.medium { border-left-color: #ffa502; }
|
||||
.vuln.low { border-left-color: #7bed9f; }
|
||||
.vuln-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.vuln-type { font-weight: 600; color: #2c3e50; }
|
||||
.vuln-severity { padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||||
.vuln-severity.critical { background: #ff4757; color: white; }
|
||||
.vuln-severity.high { background: #ff6b6b; color: white; }
|
||||
.vuln-severity.medium { background: #ffa502; color: white; }
|
||||
.vuln-severity.low { background: #7bed9f; color: #155724; }
|
||||
.vuln-location { font-family: monospace; font-size: 13px; color: #6c757d; margin-bottom: 8px; }
|
||||
.vuln-message { margin-bottom: 10px; }
|
||||
.vuln-code { background: #2d3436; color: #dfe6e9; padding: 10px; border-radius: 5px; font-family: monospace; font-size: 13px; overflow-x: auto; margin-bottom: 10px; }
|
||||
.vuln-recommendation { background: #e8f5e9; padding: 10px; border-radius: 5px; font-size: 14px; }
|
||||
.vuln-recommendation::before { content: "Recommendation: "; font-weight: 600; }
|
||||
.call-trace { background: #fff3cd; padding: 10px; border-radius: 5px; margin-top: 10px; font-size: 13px; }
|
||||
.call-trace-title { font-weight: 600; margin-bottom: 5px; }
|
||||
.call-trace-item { font-family: monospace; padding-left: 20px; }
|
||||
.meta { display: flex; gap: 15px; font-size: 12px; color: #6c757d; margin-top: 10px; }
|
||||
.meta span { background: #eee; padding: 2px 8px; border-radius: 3px; }
|
||||
footer { text-align: center; padding: 20px; color: #6c757d; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Security Scan Report</h1>
|
||||
<p>Generated: {$this->escapeHtml(date('Y-m-d H:i:s'))}</p>
|
||||
<div class="summary">
|
||||
<div class="summary-card critical"><div class="count">{$summary['critical']}</div><div class="label">Critical</div></div>
|
||||
<div class="summary-card high"><div class="count">{$summary['high']}</div><div class="label">High</div></div>
|
||||
<div class="summary-card medium"><div class="count">{$summary['medium']}</div><div class="label">Medium</div></div>
|
||||
<div class="summary-card low"><div class="count">{$summary['low']}</div><div class="label">Low</div></div>
|
||||
<div class="summary-card"><div class="count">{$summary['total']}</div><div class="label">Total</div></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
HTML;
|
||||
|
||||
foreach (['critical', 'high', 'medium', 'low'] as $severity) {
|
||||
if (empty($grouped[$severity])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = count($grouped[$severity]);
|
||||
$html .= "<div class=\"section\">\n";
|
||||
$html .= "<h2 class=\"section-title\">" . ucfirst($severity) . " Severity ({$count})</h2>\n";
|
||||
|
||||
foreach ($grouped[$severity] as $vuln) {
|
||||
$html .= $this->formatVulnerabilityHtml($vuln);
|
||||
}
|
||||
|
||||
$html .= "</div>\n";
|
||||
}
|
||||
|
||||
$html .= <<<HTML
|
||||
<footer>
|
||||
<p>Generated by PHP/Laravel Security Linter</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format vulnerability for HTML output
|
||||
*/
|
||||
private function formatVulnerabilityHtml(Vulnerability $vuln): string
|
||||
{
|
||||
$severity = $vuln->getSeverity();
|
||||
$type = $this->escapeHtml($vuln->getType());
|
||||
$message = $this->escapeHtml($vuln->getMessage());
|
||||
$file = $this->escapeHtml($vuln->getFile());
|
||||
$line = $vuln->getLine();
|
||||
|
||||
$html = "<div class=\"vuln {$severity}\">\n";
|
||||
$html .= "<div class=\"vuln-header\">\n";
|
||||
$html .= "<span class=\"vuln-type\">{$type}</span>\n";
|
||||
$html .= "<span class=\"vuln-severity {$severity}\">{$severity}</span>\n";
|
||||
$html .= "</div>\n";
|
||||
$html .= "<div class=\"vuln-location\">{$file}:{$line}</div>\n";
|
||||
$html .= "<div class=\"vuln-message\">{$message}</div>\n";
|
||||
|
||||
if ($vuln->getCode()) {
|
||||
$code = $this->escapeHtml($vuln->getCode());
|
||||
$html .= "<div class=\"vuln-code\">{$code}</div>\n";
|
||||
}
|
||||
|
||||
if (!empty($vuln->getCallTrace())) {
|
||||
$html .= "<div class=\"call-trace\">\n";
|
||||
$html .= "<div class=\"call-trace-title\">Call Trace:</div>\n";
|
||||
foreach ($vuln->getCallTrace() as $trace) {
|
||||
$func = $this->escapeHtml($trace['function']);
|
||||
$tFile = $this->escapeHtml($trace['file']);
|
||||
$tLine = $trace['line'];
|
||||
$html .= "<div class=\"call-trace-item\">→ {$func} at {$tFile}:{$tLine}</div>\n";
|
||||
}
|
||||
$html .= "</div>\n";
|
||||
}
|
||||
|
||||
if ($vuln->getRecommendation()) {
|
||||
$rec = $this->escapeHtml($vuln->getRecommendation());
|
||||
$html .= "<div class=\"vuln-recommendation\">{$rec}</div>\n";
|
||||
}
|
||||
|
||||
$html .= "<div class=\"meta\">\n";
|
||||
if ($vuln->getCweId()) {
|
||||
$html .= "<span>CWE: {$this->escapeHtml($vuln->getCweId())}</span>\n";
|
||||
}
|
||||
if ($vuln->getOwaspCategory()) {
|
||||
$html .= "<span>OWASP: {$this->escapeHtml($vuln->getOwaspCategory())}</span>\n";
|
||||
}
|
||||
$html .= "</div>\n";
|
||||
$html .= "</div>\n";
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SARIF format (for IDE integration)
|
||||
*/
|
||||
private function generateSarif(array $vulnerabilities): string
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
$results[] = [
|
||||
'ruleId' => $vuln->getType(),
|
||||
'level' => $this->sarifLevel($vuln->getSeverity()),
|
||||
'message' => ['text' => $vuln->getMessage()],
|
||||
'locations' => [
|
||||
[
|
||||
'physicalLocation' => [
|
||||
'artifactLocation' => ['uri' => $vuln->getFile()],
|
||||
'region' => ['startLine' => $vuln->getLine()],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$sarif = [
|
||||
'version' => '2.1.0',
|
||||
'$schema' => 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
||||
'runs' => [
|
||||
[
|
||||
'tool' => [
|
||||
'driver' => [
|
||||
'name' => 'PHP/Laravel Security Linter',
|
||||
'version' => '1.0.0',
|
||||
],
|
||||
],
|
||||
'results' => $results,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Markdown report
|
||||
*/
|
||||
private function generateMarkdown(array $vulnerabilities): string
|
||||
{
|
||||
if (empty($vulnerabilities)) {
|
||||
return "# Security Scan Results\n\nNo vulnerabilities found.\n";
|
||||
}
|
||||
|
||||
$grouped = $this->groupBySeverity($vulnerabilities);
|
||||
$summary = $this->getSummary($vulnerabilities);
|
||||
|
||||
$md = "# Security Scan Results\n\n";
|
||||
$md .= "Generated: " . date('Y-m-d H:i:s') . "\n\n";
|
||||
$md .= "## Summary\n\n";
|
||||
$md .= "| Severity | Count |\n";
|
||||
$md .= "|----------|-------|\n";
|
||||
$md .= "| Critical | {$summary['critical']} |\n";
|
||||
$md .= "| High | {$summary['high']} |\n";
|
||||
$md .= "| Medium | {$summary['medium']} |\n";
|
||||
$md .= "| Low | {$summary['low']} |\n";
|
||||
$md .= "| **Total** | **{$summary['total']}** |\n\n";
|
||||
|
||||
foreach (['critical', 'high', 'medium', 'low'] as $severity) {
|
||||
if (empty($grouped[$severity])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$md .= "## " . ucfirst($severity) . " Severity\n\n";
|
||||
|
||||
foreach ($grouped[$severity] as $vuln) {
|
||||
$md .= "### {$vuln->getType()}\n\n";
|
||||
$md .= "**Location:** `{$vuln->getFile()}:{$vuln->getLine()}`\n\n";
|
||||
$md .= "{$vuln->getMessage()}\n\n";
|
||||
|
||||
if ($vuln->getCode()) {
|
||||
$md .= "```php\n{$vuln->getCode()}\n```\n\n";
|
||||
}
|
||||
|
||||
if ($vuln->getRecommendation()) {
|
||||
$md .= "> **Recommendation:** {$vuln->getRecommendation()}\n\n";
|
||||
}
|
||||
|
||||
$md .= "---\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $md;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group vulnerabilities by severity
|
||||
*/
|
||||
private function groupBySeverity(array $vulnerabilities): array
|
||||
{
|
||||
$grouped = ['critical' => [], 'high' => [], 'medium' => [], 'low' => []];
|
||||
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
$grouped[$vuln->getSeverity()][] = $vuln;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics
|
||||
*/
|
||||
private function getSummary(array $vulnerabilities): array
|
||||
{
|
||||
$summary = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0, 'total' => 0];
|
||||
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
$summary[$vuln->getSeverity()]++;
|
||||
$summary['total']++;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert severity to SARIF level
|
||||
*/
|
||||
private function sarifLevel(string $severity): string
|
||||
{
|
||||
return match ($severity) {
|
||||
'critical', 'high' => 'error',
|
||||
'medium' => 'warning',
|
||||
default => 'note',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML entities
|
||||
*/
|
||||
private function escapeHtml(string $text): string
|
||||
{
|
||||
return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
}
|
||||
147
src/Report/Vulnerability.php
Normal file
147
src/Report/Vulnerability.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Report;
|
||||
|
||||
/**
|
||||
* Represents a detected security vulnerability
|
||||
*/
|
||||
class Vulnerability
|
||||
{
|
||||
public const SEVERITY_CRITICAL = 'critical';
|
||||
public const SEVERITY_HIGH = 'high';
|
||||
public const SEVERITY_MEDIUM = 'medium';
|
||||
public const SEVERITY_LOW = 'low';
|
||||
|
||||
private string $type;
|
||||
private string $severity;
|
||||
private string $message;
|
||||
private string $file;
|
||||
private int $line;
|
||||
private ?string $code;
|
||||
private ?string $recommendation;
|
||||
private array $callTrace;
|
||||
private ?string $cweId;
|
||||
private ?string $owaspCategory;
|
||||
|
||||
public function __construct(
|
||||
string $type,
|
||||
string $severity,
|
||||
string $message,
|
||||
string $file,
|
||||
int $line,
|
||||
?string $code = null,
|
||||
?string $recommendation = null,
|
||||
array $callTrace = [],
|
||||
?string $cweId = null,
|
||||
?string $owaspCategory = null
|
||||
) {
|
||||
$this->type = $type;
|
||||
$this->severity = $severity;
|
||||
$this->message = $message;
|
||||
$this->file = $file;
|
||||
$this->line = $line;
|
||||
$this->code = $code;
|
||||
$this->recommendation = $recommendation;
|
||||
$this->callTrace = $callTrace;
|
||||
$this->cweId = $cweId;
|
||||
$this->owaspCategory = $owaspCategory;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getSeverity(): string
|
||||
{
|
||||
return $this->severity;
|
||||
}
|
||||
|
||||
public function getMessage(): string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function getFile(): string
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
|
||||
public function getLine(): int
|
||||
{
|
||||
return $this->line;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function getRecommendation(): ?string
|
||||
{
|
||||
return $this->recommendation;
|
||||
}
|
||||
|
||||
public function getCallTrace(): array
|
||||
{
|
||||
return $this->callTrace;
|
||||
}
|
||||
|
||||
public function getCweId(): ?string
|
||||
{
|
||||
return $this->cweId;
|
||||
}
|
||||
|
||||
public function getOwaspCategory(): ?string
|
||||
{
|
||||
return $this->owaspCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique key for deduplication
|
||||
*/
|
||||
public function getUniqueKey(): string
|
||||
{
|
||||
return md5("{$this->type}:{$this->file}:{$this->line}:{$this->message}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for JSON serialization
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'severity' => $this->severity,
|
||||
'message' => $this->message,
|
||||
'file' => $this->file,
|
||||
'line' => $this->line,
|
||||
'code' => $this->code,
|
||||
'recommendation' => $this->recommendation,
|
||||
'callTrace' => $this->callTrace,
|
||||
'cweId' => $this->cweId,
|
||||
'owaspCategory' => $this->owaspCategory,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
$data['type'] ?? 'unknown',
|
||||
$data['severity'] ?? self::SEVERITY_MEDIUM,
|
||||
$data['message'] ?? '',
|
||||
$data['file'] ?? '',
|
||||
$data['line'] ?? 0,
|
||||
$data['code'] ?? null,
|
||||
$data['recommendation'] ?? null,
|
||||
$data['callTrace'] ?? [],
|
||||
$data['cweId'] ?? null,
|
||||
$data['owaspCategory'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
883
src/Rules/AuthenticationRule.php
Normal file
883
src/Rules/AuthenticationRule.php
Normal file
@@ -0,0 +1,883 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Rules;
|
||||
|
||||
use PhpParser\Node;
|
||||
use SecurityLinter\Analyzer\TaintTracker;
|
||||
use SecurityLinter\Report\Vulnerability;
|
||||
|
||||
/**
|
||||
* Detects Authentication and Password Security vulnerabilities
|
||||
*
|
||||
* Checks for:
|
||||
* - Weak password hashing (md5, sha1, sha256 for passwords)
|
||||
* - Missing bcrypt/argon2id
|
||||
* - Hardcoded credentials
|
||||
* - Insecure password comparison
|
||||
* - Missing password validation
|
||||
*/
|
||||
class AuthenticationRule extends BaseRule
|
||||
{
|
||||
/** @var array Weak hash functions for passwords */
|
||||
private const WEAK_HASH_FUNCTIONS = [
|
||||
'md5', 'sha1', 'sha256', 'sha512',
|
||||
'hash', 'crypt',
|
||||
];
|
||||
|
||||
/** @var array Secure password functions */
|
||||
private const SECURE_PASSWORD_FUNCTIONS = [
|
||||
'password_hash', 'password_verify',
|
||||
'Hash::make', 'Hash::check',
|
||||
'bcrypt', 'argon2id',
|
||||
];
|
||||
|
||||
/** @var array Common credential variable names */
|
||||
private const CREDENTIAL_VAR_NAMES = [
|
||||
'password', 'passwd', 'pwd', 'secret',
|
||||
'api_key', 'apikey', 'api_secret',
|
||||
'token', 'auth_token', 'access_token',
|
||||
'private_key', 'secret_key',
|
||||
];
|
||||
|
||||
/** @var array Patterns that indicate i18n/message keys (not credentials) */
|
||||
private const I18N_KEY_PATTERNS = [
|
||||
'/^[a-z_]+\.[a-z_]+/', // dot notation like "auth.password"
|
||||
'/^[a-z_]+:[a-z_]+/', // colon notation like "auth:password"
|
||||
'/\.rec$/', // ends with .rec (recommendation)
|
||||
'/\.msg$/', // ends with .msg
|
||||
'/\.message$/', // ends with .message
|
||||
'/\.error$/', // ends with .error
|
||||
'/\.description$/', // ends with .description
|
||||
'/\.title$/', // ends with .title
|
||||
'/\.label$/', // ends with .label
|
||||
];
|
||||
|
||||
/** @var array Laravel validation rule patterns */
|
||||
private const VALIDATION_RULE_PATTERNS = [
|
||||
'required', 'nullable', 'sometimes', 'present', 'filled',
|
||||
'string', 'integer', 'numeric', 'boolean', 'array', 'json',
|
||||
'email', 'url', 'uuid', 'ulid', 'ip', 'ipv4', 'ipv6',
|
||||
'date', 'date_format', 'before', 'after', 'timezone',
|
||||
'file', 'image', 'mimes', 'mimetypes',
|
||||
'min:', 'max:', 'size:', 'between:', 'digits:', 'digits_between:',
|
||||
'in:', 'not_in:', 'exists:', 'unique:', 'regex:', 'confirmed',
|
||||
'alpha', 'alpha_num', 'alpha_dash', 'active_url',
|
||||
'accepted', 'declined', 'prohibited', 'exclude',
|
||||
];
|
||||
|
||||
/** @var array Laravel cast type patterns */
|
||||
private const LARAVEL_CASTS = [
|
||||
'hashed', 'encrypted', 'datetime', 'date', 'timestamp',
|
||||
'boolean', 'bool', 'integer', 'int', 'real', 'float', 'double',
|
||||
'string', 'array', 'json', 'object', 'collection', 'immutable_date',
|
||||
'immutable_datetime', 'decimal:', 'AsStringable',
|
||||
'AsArrayObject', 'AsCollection', 'AsEncryptedCollection',
|
||||
'AsEncryptedArrayObject', 'Attribute',
|
||||
];
|
||||
|
||||
/** @var array File paths that are typically i18n/translation files */
|
||||
private const I18N_FILE_PATTERNS = [
|
||||
'/I18n/',
|
||||
'/lang/',
|
||||
'/locales/',
|
||||
'/translations/',
|
||||
'/messages/',
|
||||
'/resources\/lang/',
|
||||
];
|
||||
|
||||
/** @var array Non-security uses of md5 (unique ID generation, checksums) */
|
||||
private const NON_SECURITY_MD5_CONTEXTS = [
|
||||
'key', 'id', 'unique', 'identifier', 'cache', 'hash_key',
|
||||
'etag', 'checksum', 'fingerprint', 'signature',
|
||||
];
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->msg('auth.name');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Detects Authentication and Password Security issues';
|
||||
}
|
||||
|
||||
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
|
||||
{
|
||||
$vulnerabilities = [];
|
||||
|
||||
$this->traverse($ast, function (Node $node) use ($filePath, $taintTracker, &$vulnerabilities) {
|
||||
// Check function calls
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$this->checkFunctionCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check static method calls
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
$this->checkStaticCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check assignments for hardcoded credentials
|
||||
if ($node instanceof Node\Expr\Assign) {
|
||||
$this->checkAssignment($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check comparisons for timing attacks
|
||||
if ($node instanceof Node\Expr\BinaryOp\Equal
|
||||
|| $node instanceof Node\Expr\BinaryOp\Identical
|
||||
|| $node instanceof Node\Expr\BinaryOp\NotEqual
|
||||
|| $node instanceof Node\Expr\BinaryOp\NotIdentical) {
|
||||
$this->checkComparison($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check array definitions for hardcoded credentials
|
||||
if ($node instanceof Node\Expr\Array_) {
|
||||
$this->checkArrayCredentials($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return $vulnerabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check function calls for weak password handling
|
||||
*/
|
||||
private function checkFunctionCall(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$funcName = $this->getCallName($node);
|
||||
|
||||
if ($funcName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for weak hash functions used for passwords
|
||||
if (in_array($funcName, self::WEAK_HASH_FUNCTIONS)) {
|
||||
$this->checkWeakHash($node, $funcName, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check password_hash for weak algorithm
|
||||
if ($funcName === 'password_hash') {
|
||||
$this->checkPasswordHash($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check for base64 encoding of passwords (not encryption)
|
||||
if (in_array($funcName, ['base64_encode', 'base64_decode'])) {
|
||||
$this->checkBase64Password($node, $funcName, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check for strcmp/strcasecmp timing attacks
|
||||
if (in_array($funcName, ['strcmp', 'strcasecmp'])) {
|
||||
$this->checkStringCompare($node, $funcName, $filePath, $vulnerabilities);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check weak hash function usage
|
||||
*/
|
||||
private function checkWeakHash(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $funcName,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
// Check context - is this used for password?
|
||||
$args = $this->getArguments($node);
|
||||
$isPasswordContext = false;
|
||||
|
||||
if (!empty($args)) {
|
||||
$arg = $args[0]->value;
|
||||
|
||||
// Check if argument variable name suggests password
|
||||
if ($arg instanceof Node\Expr\Variable) {
|
||||
$varName = strtolower($this->getVariableName($arg));
|
||||
$isPasswordContext = $this->isCredentialName($varName);
|
||||
}
|
||||
|
||||
// Check for hash() function with password-related input
|
||||
if ($funcName === 'hash' && count($args) >= 2) {
|
||||
$arg = $args[1]->value;
|
||||
if ($arg instanceof Node\Expr\Variable) {
|
||||
$varName = strtolower($this->getVariableName($arg));
|
||||
$isPasswordContext = $this->isCredentialName($varName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($isPasswordContext) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('auth.weak_hash', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.weak_hash.rec'),
|
||||
[],
|
||||
'CWE-328',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
} else {
|
||||
// Skip non-security uses (unique key generation, cache keys, etc.)
|
||||
if ($this->isNonSecurityHashContext($node)) {
|
||||
return; // Don't flag - it's used for ID/key generation
|
||||
}
|
||||
|
||||
// Still flag as potential issue (but low severity)
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('auth.weak_hash_review', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.weak_hash_review.rec'),
|
||||
[],
|
||||
'CWE-328',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check password_hash algorithm
|
||||
*/
|
||||
private function checkPasswordHash(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if (count($args) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$algoArg = $args[1]->value;
|
||||
|
||||
// Check for PASSWORD_DEFAULT (OK but could specify stronger)
|
||||
if ($algoArg instanceof Node\Expr\ConstFetch) {
|
||||
$constName = $algoArg->name->toString();
|
||||
|
||||
if ($constName === 'PASSWORD_MD5' || $constName === 'PASSWORD_SHA1') {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('auth.weak_algo', ['algo' => $constName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.weak_algo.rec'),
|
||||
[],
|
||||
'CWE-328',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check cost parameter if provided
|
||||
if (count($args) >= 3) {
|
||||
$optionsArg = $args[2]->value;
|
||||
if ($optionsArg instanceof Node\Expr\Array_) {
|
||||
foreach ($optionsArg->items as $item) {
|
||||
if ($item && $item->key instanceof Node\Scalar\String_) {
|
||||
if ($item->key->value === 'cost' && $item->value instanceof Node\Scalar\LNumber) {
|
||||
$cost = $item->value->value;
|
||||
if ($cost < 10) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('auth.low_cost', ['cost' => $cost]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.low_cost.rec'),
|
||||
[],
|
||||
'CWE-916',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check static method calls
|
||||
*/
|
||||
private function checkStaticCall(
|
||||
Node\Expr\StaticCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
|
||||
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
|
||||
|
||||
// Check Laravel Hash facade
|
||||
if (str_contains($className, 'Hash')) {
|
||||
if ($methodName === 'make') {
|
||||
// Check for weak driver options
|
||||
$args = $this->getArguments($node);
|
||||
if (count($args) >= 2) {
|
||||
$optionsArg = $args[1]->value;
|
||||
if ($optionsArg instanceof Node\Expr\Array_) {
|
||||
foreach ($optionsArg->items as $item) {
|
||||
if ($item && $item->key instanceof Node\Scalar\String_) {
|
||||
if ($item->key->value === 'rounds' && $item->value instanceof Node\Scalar\LNumber) {
|
||||
$rounds = $item->value->value;
|
||||
if ($rounds < 10) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('auth.low_rounds', ['rounds' => $rounds]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.low_rounds.rec'),
|
||||
[],
|
||||
'CWE-916',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Crypt facade usage
|
||||
if (str_contains($className, 'Crypt') && $methodName === 'encrypt') {
|
||||
// Crypt is for encryption, not password hashing
|
||||
// Check if it's being used for passwords
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $args[0]->value instanceof Node\Expr\Variable) {
|
||||
$varName = strtolower($this->getVariableName($args[0]->value));
|
||||
if ($this->isCredentialName($varName)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('auth.encrypt_password'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.encrypt_password.rec'),
|
||||
[],
|
||||
'CWE-327',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check assignments for hardcoded credentials
|
||||
*/
|
||||
private function checkAssignment(
|
||||
Node\Expr\Assign $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
// Get variable name
|
||||
$varName = '';
|
||||
if ($node->var instanceof Node\Expr\Variable) {
|
||||
$varName = strtolower($this->getVariableName($node->var));
|
||||
} elseif ($node->var instanceof Node\Expr\PropertyFetch) {
|
||||
if ($node->var->name instanceof Node\Identifier) {
|
||||
$varName = strtolower($node->var->name->toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a credential variable
|
||||
if (!$this->isCredentialName($varName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if assigned a hardcoded string
|
||||
if ($node->expr instanceof Node\Scalar\String_) {
|
||||
$value = $node->expr->value;
|
||||
|
||||
// Ignore empty strings and placeholder values
|
||||
if (empty($value) || $this->isPlaceholder($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('auth.hardcoded', ['var' => $varName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.hardcoded.rec'),
|
||||
[],
|
||||
'CWE-798',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check comparisons for timing attacks
|
||||
*/
|
||||
private function checkComparison(
|
||||
Node $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$left = $node->left ?? null;
|
||||
$right = $node->right ?? null;
|
||||
|
||||
if ($left === null || $right === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if comparing credential-like variables
|
||||
$leftIsCredential = $this->isCredentialVariable($left);
|
||||
$rightIsCredential = $this->isCredentialVariable($right);
|
||||
|
||||
if ($leftIsCredential || $rightIsCredential) {
|
||||
// Check if it's a hash comparison (vulnerable to timing)
|
||||
$isHashComparison = $this->looksLikeHashComparison($left, $right);
|
||||
|
||||
if ($isHashComparison) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('auth.timing'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.timing.rec'),
|
||||
[],
|
||||
'CWE-208',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check array for hardcoded credentials
|
||||
*/
|
||||
private function checkArrayCredentials(
|
||||
Node\Expr\Array_ $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
// Skip i18n/translation files entirely
|
||||
if ($this->isI18nFile($filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($node->items as $item) {
|
||||
if ($item === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check key name
|
||||
$keyName = '';
|
||||
$originalKeyName = '';
|
||||
if ($item->key instanceof Node\Scalar\String_) {
|
||||
$originalKeyName = $item->key->value;
|
||||
$keyName = strtolower($originalKeyName);
|
||||
}
|
||||
|
||||
// Skip i18n message keys (dot notation, colon notation, etc.)
|
||||
if ($this->isI18nKey($originalKeyName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->isCredentialName($keyName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if value is hardcoded
|
||||
if ($item->value instanceof Node\Scalar\String_) {
|
||||
$value = $item->value->value;
|
||||
|
||||
// Skip Laravel validation rules
|
||||
if ($this->isLaravelValidationRule($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip Laravel casts
|
||||
if ($this->isLaravelCast($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!empty($value) && !$this->isPlaceholder($value) && !$this->isDescriptiveMessage($value)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('auth.hardcoded_array', ['key' => $keyName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.hardcoded_array.rec'),
|
||||
[],
|
||||
'CWE-798',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check base64 encoding for password context
|
||||
*/
|
||||
private function checkBase64Password(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $funcName,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if (empty($args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$arg = $args[0]->value;
|
||||
|
||||
if ($arg instanceof Node\Expr\Variable) {
|
||||
$varName = strtolower($this->getVariableName($arg));
|
||||
|
||||
if ($this->isCredentialName($varName)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('auth.base64', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.base64.rec'),
|
||||
[],
|
||||
'CWE-327',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check strcmp/strcasecmp for credential comparison
|
||||
*/
|
||||
private function checkStringCompare(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $funcName,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if (count($args) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasCredential = false;
|
||||
foreach ($args as $arg) {
|
||||
if ($arg->value instanceof Node\Expr\Variable) {
|
||||
$varName = strtolower($this->getVariableName($arg->value));
|
||||
if ($this->isCredentialName($varName)) {
|
||||
$hasCredential = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasCredential) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('auth.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('auth.strcmp', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('auth.strcmp.rec'),
|
||||
[],
|
||||
'CWE-208',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variable name suggests credentials
|
||||
*/
|
||||
private function isCredentialName(string $name): bool
|
||||
{
|
||||
foreach (self::CREDENTIAL_VAR_NAMES as $credName) {
|
||||
if (str_contains($name, $credName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if variable is a credential variable
|
||||
*/
|
||||
private function isCredentialVariable(Node $node): bool
|
||||
{
|
||||
if ($node instanceof Node\Expr\Variable) {
|
||||
if (!is_string($node->name)) {
|
||||
return false;
|
||||
}
|
||||
$name = strtolower($node->name);
|
||||
return $this->isCredentialName($name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks like a placeholder
|
||||
*/
|
||||
private function isPlaceholder(string $value): bool
|
||||
{
|
||||
$placeholders = [
|
||||
'xxx', 'password', 'secret', 'changeme', 'your_',
|
||||
'your-', '<', '>', '{', '}', 'TODO', 'FIXME',
|
||||
'env(', 'config(', 'getenv',
|
||||
];
|
||||
|
||||
$lower = strtolower($value);
|
||||
foreach ($placeholders as $placeholder) {
|
||||
if (str_contains($lower, strtolower($placeholder))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if comparison looks like hash comparison
|
||||
*/
|
||||
private function looksLikeHashComparison(Node $left, Node $right): bool
|
||||
{
|
||||
// Check variable names that suggest hash/token comparison
|
||||
$names = [];
|
||||
|
||||
if ($left instanceof Node\Expr\Variable) {
|
||||
$names[] = strtolower($this->getVariableName($left));
|
||||
}
|
||||
if ($right instanceof Node\Expr\Variable) {
|
||||
$names[] = strtolower($this->getVariableName($right));
|
||||
}
|
||||
|
||||
$hashIndicators = ['hash', 'token', 'signature', 'hmac', 'digest', 'checksum'];
|
||||
|
||||
foreach ($names as $name) {
|
||||
foreach ($hashIndicators as $indicator) {
|
||||
if (str_contains($name, $indicator)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file path is an i18n/translation file
|
||||
*/
|
||||
private function isI18nFile(string $filePath): bool
|
||||
{
|
||||
foreach (self::I18N_FILE_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $filePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if array key is an i18n message key (not a credential key)
|
||||
*/
|
||||
private function isI18nKey(string $key): bool
|
||||
{
|
||||
// Empty keys are not i18n
|
||||
if (empty($key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check against i18n patterns
|
||||
foreach (self::I18N_KEY_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks like a descriptive message rather than a credential
|
||||
*/
|
||||
private function isDescriptiveMessage(string $value): bool
|
||||
{
|
||||
// Strings with multiple spaces are likely descriptions/messages
|
||||
if (substr_count($value, ' ') >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strings ending with punctuation are likely messages
|
||||
if (preg_match('/[.!?。!?]$/', $value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Contains typical message indicators
|
||||
$messageIndicators = [
|
||||
'してください', // Japanese polite request
|
||||
'ください', // Japanese please
|
||||
'です。', // Japanese sentence ending
|
||||
'ます。', // Japanese sentence ending
|
||||
'ません', // Japanese negative
|
||||
'please',
|
||||
'should',
|
||||
'must',
|
||||
'warning',
|
||||
'error',
|
||||
'invalid',
|
||||
'expired',
|
||||
'required',
|
||||
'missing',
|
||||
'detected',
|
||||
'found',
|
||||
'failed',
|
||||
'success',
|
||||
'unable',
|
||||
'cannot',
|
||||
'not found',
|
||||
'not allowed',
|
||||
'use ',
|
||||
'Use ',
|
||||
];
|
||||
|
||||
$lower = strtolower($value);
|
||||
foreach ($messageIndicators as $indicator) {
|
||||
if (str_contains($value, $indicator) || str_contains($lower, strtolower($indicator))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks like a Laravel validation rule
|
||||
*/
|
||||
private function isLaravelValidationRule(string $value): bool
|
||||
{
|
||||
// Empty value is not a validation rule
|
||||
if (empty($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for pipe-separated rules (e.g., 'required|string|max:255')
|
||||
if (str_contains($value, '|')) {
|
||||
$parts = explode('|', $value);
|
||||
foreach ($parts as $part) {
|
||||
$ruleName = explode(':', $part)[0];
|
||||
foreach (self::VALIDATION_RULE_PATTERNS as $pattern) {
|
||||
$patternName = rtrim($pattern, ':');
|
||||
if ($ruleName === $patternName || str_starts_with($part, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check single rule
|
||||
$ruleName = explode(':', $value)[0];
|
||||
foreach (self::VALIDATION_RULE_PATTERNS as $pattern) {
|
||||
$patternName = rtrim($pattern, ':');
|
||||
if ($ruleName === $patternName || str_starts_with($value, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks like a Laravel cast definition
|
||||
*/
|
||||
private function isLaravelCast(string $value): bool
|
||||
{
|
||||
if (empty($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::LARAVEL_CASTS as $cast) {
|
||||
if ($value === rtrim($cast, ':') || str_starts_with($value, $cast)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if md5/hash usage is for non-security purposes (ID generation, etc.)
|
||||
*/
|
||||
private function isNonSecurityHashContext(Node $node): bool
|
||||
{
|
||||
// Check if argument is a concatenated/interpolated string (typical for unique key generation)
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args)) {
|
||||
$arg = $args[0]->value;
|
||||
// Interpolated strings or concatenations suggest key generation
|
||||
if ($arg instanceof Node\Scalar\InterpolatedString) {
|
||||
return true;
|
||||
}
|
||||
if ($arg instanceof Node\Scalar\Encapsed) {
|
||||
return true;
|
||||
}
|
||||
if ($arg instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check parent context for assignment to key/id variables
|
||||
$parent = $node->getAttribute('parent');
|
||||
|
||||
if ($parent instanceof Node\Expr\Assign) {
|
||||
$varName = '';
|
||||
if ($parent->var instanceof Node\Expr\Variable) {
|
||||
$varName = strtolower($this->getVariableName($parent->var));
|
||||
}
|
||||
|
||||
foreach (self::NON_SECURITY_MD5_CONTEXTS as $context) {
|
||||
if (str_contains($varName, $context)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if result is concatenated with other strings (unique key generation)
|
||||
if ($parent instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if this is a return statement (common in getUniqueKey, getId methods)
|
||||
if ($parent instanceof Node\Stmt\Return_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
271
src/Rules/BaseRule.php
Normal file
271
src/Rules/BaseRule.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Rules;
|
||||
|
||||
use PhpParser\Node;
|
||||
use PhpParser\NodeTraverser;
|
||||
use PhpParser\NodeVisitorAbstract;
|
||||
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
|
||||
use SecurityLinter\Analyzer\TaintTracker;
|
||||
use SecurityLinter\I18n\Messages;
|
||||
use SecurityLinter\Report\Vulnerability;
|
||||
|
||||
/**
|
||||
* Base class for security rules with common functionality
|
||||
*/
|
||||
abstract class BaseRule implements RuleInterface
|
||||
{
|
||||
protected PrettyPrinter $printer;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->printer = new PrettyPrinter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized message
|
||||
*/
|
||||
protected function msg(string $key, array $params = []): string
|
||||
{
|
||||
return Messages::get($key, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a vulnerability instance
|
||||
*/
|
||||
protected function createVulnerability(
|
||||
string $type,
|
||||
string $severity,
|
||||
string $message,
|
||||
string $file,
|
||||
int $line,
|
||||
?Node $node = null,
|
||||
?string $recommendation = null,
|
||||
array $callTrace = [],
|
||||
?string $cweId = null,
|
||||
?string $owaspCategory = null
|
||||
): Vulnerability {
|
||||
$code = $node ? $this->getCodeSnippet($node) : null;
|
||||
|
||||
return new Vulnerability(
|
||||
$type,
|
||||
$severity,
|
||||
$message,
|
||||
$file,
|
||||
$line,
|
||||
$code,
|
||||
$recommendation,
|
||||
$callTrace,
|
||||
$cweId,
|
||||
$owaspCategory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get code snippet from node
|
||||
*/
|
||||
protected function getCodeSnippet(Node $node): string
|
||||
{
|
||||
try {
|
||||
$code = $this->printer->prettyPrint([$node]);
|
||||
// Limit length
|
||||
if (strlen($code) > 200) {
|
||||
$code = substr($code, 0, 197) . '...';
|
||||
}
|
||||
return trim($code);
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse AST with a visitor
|
||||
*/
|
||||
protected function traverse(array $ast, callable $visitor): void
|
||||
{
|
||||
$traverser = new NodeTraverser();
|
||||
$traverser->addVisitor(new class($visitor) extends NodeVisitorAbstract {
|
||||
private $callback;
|
||||
|
||||
public function __construct(callable $callback)
|
||||
{
|
||||
$this->callback = $callback;
|
||||
}
|
||||
|
||||
public function enterNode(Node $node)
|
||||
{
|
||||
return ($this->callback)($node);
|
||||
}
|
||||
});
|
||||
$traverser->traverse($ast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get function/method name from a call node
|
||||
*/
|
||||
protected function getCallName(Node $node): ?string
|
||||
{
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
if ($node->name instanceof Node\Name) {
|
||||
return $node->name->toString();
|
||||
}
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\MethodCall) {
|
||||
if ($node->name instanceof Node\Identifier) {
|
||||
return $node->name->toString();
|
||||
}
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
$class = $node->class instanceof Node\Name ? $node->class->toString() : null;
|
||||
$method = $node->name instanceof Node\Identifier ? $node->name->toString() : null;
|
||||
if ($class && $method) {
|
||||
return "{$class}::{$method}";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node is a string literal
|
||||
*/
|
||||
protected function isStringLiteral(Node $node): bool
|
||||
{
|
||||
return $node instanceof Node\Scalar\String_;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node is a numeric literal
|
||||
*/
|
||||
protected function isNumericLiteral(Node $node): bool
|
||||
{
|
||||
return $node instanceof Node\Scalar\LNumber
|
||||
|| $node instanceof Node\Scalar\DNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node is a safe constant
|
||||
*/
|
||||
protected function isSafeConstant(Node $node): bool
|
||||
{
|
||||
if ($node instanceof Node\Expr\ConstFetch) {
|
||||
$name = strtolower($node->name->toString());
|
||||
return in_array($name, ['true', 'false', 'null']);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if node contains user input (quick check)
|
||||
*/
|
||||
protected function containsUserInput(Node $node): bool
|
||||
{
|
||||
// Check superglobals
|
||||
if ($node instanceof Node\Expr\Variable) {
|
||||
if (!is_string($node->name)) {
|
||||
return true; // Variable variables - assume user input for safety
|
||||
}
|
||||
$name = $node->name;
|
||||
if (in_array($name, ['_GET', '_POST', '_REQUEST', '_COOKIE', '_FILES', '_SERVER'])) {
|
||||
return true;
|
||||
}
|
||||
// Check for request-related variable names
|
||||
if (in_array($name, ['request', 'input', 'data', 'params'])) {
|
||||
return true; // Might be user input
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check array access to superglobals
|
||||
if ($node instanceof Node\Expr\ArrayDimFetch) {
|
||||
return $this->containsUserInput($node->var);
|
||||
}
|
||||
|
||||
// Recursively check child nodes
|
||||
foreach ($node->getSubNodeNames() as $name) {
|
||||
$subNode = $node->{$name};
|
||||
if ($subNode instanceof Node && $this->containsUserInput($subNode)) {
|
||||
return true;
|
||||
}
|
||||
if (is_array($subNode)) {
|
||||
foreach ($subNode as $item) {
|
||||
if ($item instanceof Node && $this->containsUserInput($item)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all arguments from a function call
|
||||
* Filters out VariadicPlaceholder nodes (spread operator ...)
|
||||
*/
|
||||
protected function getArguments(Node $node): array
|
||||
{
|
||||
if ($node instanceof Node\Expr\FuncCall
|
||||
|| $node instanceof Node\Expr\MethodCall
|
||||
|| $node instanceof Node\Expr\StaticCall) {
|
||||
// Filter out VariadicPlaceholder (spread operator ...)
|
||||
return array_values(array_filter(
|
||||
$node->args,
|
||||
fn($arg) => $arg instanceof Node\Arg
|
||||
));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get argument at specific position
|
||||
*/
|
||||
protected function getArgument(Node $node, int $position): ?Node\Arg
|
||||
{
|
||||
$args = $this->getArguments($node);
|
||||
return $args[$position] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get a variable name from a Variable node
|
||||
* Returns empty string if the name is not a string (e.g., variable variables like $$foo)
|
||||
*/
|
||||
protected function getVariableName(Node $node): string
|
||||
{
|
||||
if ($node instanceof Node\Expr\Variable) {
|
||||
return is_string($node->name) ? $node->name : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method call is on a specific variable
|
||||
*/
|
||||
protected function isMethodCallOn(Node\Expr\MethodCall $node, string $varName): bool
|
||||
{
|
||||
if ($node->var instanceof Node\Expr\Variable) {
|
||||
return $this->getVariableName($node->var) === $varName;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a static call is on a specific class
|
||||
*/
|
||||
protected function isStaticCallOn(Node\Expr\StaticCall $node, array $classNames): bool
|
||||
{
|
||||
if ($node->class instanceof Node\Name) {
|
||||
$className = $node->class->toString();
|
||||
foreach ($classNames as $name) {
|
||||
if ($className === $name || str_ends_with($className, "\\{$name}")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
928
src/Rules/CommandInjectionRule.php
Normal file
928
src/Rules/CommandInjectionRule.php
Normal file
@@ -0,0 +1,928 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Rules;
|
||||
|
||||
use PhpParser\Node;
|
||||
use PhpParser\ParserFactory;
|
||||
use SecurityLinter\Analyzer\TaintTracker;
|
||||
use SecurityLinter\Report\Vulnerability;
|
||||
|
||||
/**
|
||||
* Detects OS Command Injection vulnerabilities
|
||||
*
|
||||
* This rule performs sophisticated analysis including:
|
||||
* - Detection of tainted input in command execution
|
||||
* - Recognition of command sanitizer functions (escapeshellarg, escapeshellcmd)
|
||||
* - Detection of sanitizer-breaking patterns
|
||||
* - Recursive analysis of user-defined functions
|
||||
* - Safe Process component usage detection
|
||||
*/
|
||||
class CommandInjectionRule extends BaseRule
|
||||
{
|
||||
private const SHELL_FUNCTIONS = [
|
||||
'exec', 'shell_exec', 'system', 'passthru',
|
||||
'popen', 'proc_open', 'pcntl_exec',
|
||||
];
|
||||
|
||||
private const CODE_EXECUTION_FUNCTIONS = [
|
||||
'eval', 'assert', 'create_function',
|
||||
'call_user_func', 'call_user_func_array',
|
||||
'preg_replace',
|
||||
];
|
||||
|
||||
/** @var array Command sanitizer functions */
|
||||
private const COMMAND_SANITIZERS = [
|
||||
'escapeshellarg', // Escapes a single argument
|
||||
'escapeshellcmd', // Escapes entire command
|
||||
'basename', // Safe for filenames in commands
|
||||
'intval', 'floatval', // Type casting
|
||||
];
|
||||
|
||||
/** @var array Functions that may break command escaping */
|
||||
private const COMMAND_SANITIZER_BREAKERS = [
|
||||
'stripslashes', 'stripcslashes',
|
||||
'urldecode', 'rawurldecode',
|
||||
'html_entity_decode', 'htmlspecialchars_decode',
|
||||
'base64_decode',
|
||||
'sprintf', // Can bypass sanitization in format strings
|
||||
'str_replace', // Can be used to remove escape characters
|
||||
'preg_replace', // Can remove escape characters
|
||||
];
|
||||
|
||||
/** @var array Dangerous shell metacharacters */
|
||||
private const SHELL_METACHARACTERS = [
|
||||
';', '|', '&', '`', '$', '(', ')', '{', '}',
|
||||
'<', '>', '\n', '\r', '\\',
|
||||
];
|
||||
|
||||
/** @var array Cache for function analysis */
|
||||
private array $functionSanitizeCache = [];
|
||||
|
||||
/** @var TaintTracker|null */
|
||||
private ?TaintTracker $currentTaintTracker = null;
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->msg('cmdi.name');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Detects OS Command and Code Injection vulnerabilities';
|
||||
}
|
||||
|
||||
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
|
||||
{
|
||||
$vulnerabilities = [];
|
||||
$this->currentTaintTracker = $taintTracker;
|
||||
|
||||
$this->traverse($ast, function (Node $node) use ($filePath, $taintTracker, &$vulnerabilities) {
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$this->checkFunctionCall($node, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\ShellExec) {
|
||||
$this->checkShellExec($node, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
$this->checkStaticCall($node, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\New_) {
|
||||
$this->checkNewProcess($node, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return $vulnerabilities;
|
||||
}
|
||||
|
||||
private function checkFunctionCall(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$funcName = $this->getCallName($node);
|
||||
if ($funcName === null) return;
|
||||
|
||||
if (in_array($funcName, self::SHELL_FUNCTIONS)) {
|
||||
$this->checkShellFunction($node, $funcName, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if (in_array($funcName, self::CODE_EXECUTION_FUNCTIONS)) {
|
||||
$this->checkCodeExecution($node, $funcName, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if ($funcName === 'preg_replace') {
|
||||
$this->checkPregReplaceE($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
if (in_array($funcName, ['include', 'include_once', 'require', 'require_once'])) {
|
||||
$this->checkFileInclusion($node, $funcName, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkShellFunction(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $funcName,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
if (empty($args)) return;
|
||||
|
||||
$commandArg = $args[0]->value;
|
||||
|
||||
// Check if the command is properly sanitized
|
||||
if ($this->isCommandExpressionSanitized($commandArg, $taintTracker, $filePath, 0)) {
|
||||
// Properly sanitized - no vulnerability
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct taint check
|
||||
if ($taintTracker->isTainted($commandArg, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('cmdi.shell_func', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.shell_func.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check concatenation for tainted unsanitized parts
|
||||
if ($this->containsConcatenation($commandArg) && $this->hasTaintedUnsanitizedPart($commandArg, $taintTracker, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('cmdi.shell_func_concat', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.shell_func_concat.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-literal command that isn't verified as safe
|
||||
if (!$this->isStringLiteral($commandArg)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('cmdi.shell_func_review', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.shell_func_review.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkCodeExecution(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $funcName,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if ($funcName === 'eval') {
|
||||
if (empty($args)) return;
|
||||
$codeArg = $args[0]->value;
|
||||
|
||||
if (!$this->isStringLiteral($codeArg)) {
|
||||
$severity = $taintTracker->isTainted($codeArg, $filePath)
|
||||
? Vulnerability::SEVERITY_CRITICAL
|
||||
: Vulnerability::SEVERITY_HIGH;
|
||||
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
'Code Injection',
|
||||
$severity,
|
||||
$this->msg('cmdi.eval'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.eval.rec'),
|
||||
[],
|
||||
'CWE-94',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($funcName === 'create_function') {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
'Code Injection',
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('cmdi.create_function'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.create_function.rec'),
|
||||
[],
|
||||
'CWE-94',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
|
||||
if ($funcName === 'assert' && !empty($args) && $args[0]->value instanceof Node\Scalar\String_) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
'Code Injection',
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('cmdi.assert'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.assert.rec'),
|
||||
[],
|
||||
'CWE-94',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($funcName, ['call_user_func', 'call_user_func_array']) && !empty($args)) {
|
||||
$callbackArg = $args[0]->value;
|
||||
if ($taintTracker->isTainted($callbackArg, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
'Code Injection',
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('cmdi.call_user_func', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.call_user_func.rec'),
|
||||
[],
|
||||
'CWE-94',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkPregReplaceE(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
if (empty($args)) return;
|
||||
|
||||
$patternArg = $args[0]->value;
|
||||
if ($patternArg instanceof Node\Scalar\String_) {
|
||||
if (preg_match('/\/[a-z]*e[a-z]*$/i', $patternArg->value)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
'Code Injection',
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('cmdi.preg_replace_e'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.preg_replace_e.rec'),
|
||||
[],
|
||||
'CWE-94',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkFileInclusion(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $funcName,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
if (empty($args)) return;
|
||||
|
||||
$pathArg = $args[0]->value;
|
||||
|
||||
if ($taintTracker->isTainted($pathArg, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
'File Inclusion',
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('cmdi.file_inclusion', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.file_inclusion.rec'),
|
||||
[],
|
||||
'CWE-98',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
} elseif (!$this->isStringLiteral($pathArg) && $this->containsUserInput($pathArg)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
'File Inclusion',
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('cmdi.file_inclusion_dynamic', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.file_inclusion_dynamic.rec'),
|
||||
[],
|
||||
'CWE-98',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkShellExec(
|
||||
Node\Expr\ShellExec $node,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$hasTainted = false;
|
||||
foreach ($node->parts as $part) {
|
||||
if ($taintTracker->isTainted($part, $filePath)) {
|
||||
$hasTainted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasTainted) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('cmdi.backtick'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.backtick.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
} else {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('cmdi.backtick_review'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.backtick_review.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkStaticCall(
|
||||
Node\Expr\StaticCall $node,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
|
||||
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
|
||||
|
||||
if (str_contains($className, 'Process') && $methodName === 'fromShellCommandline') {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $taintTracker->isTainted($args[0]->value, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('cmdi.process_shell'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.process_shell.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (str_contains($className, 'Artisan') && $methodName === 'call') {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $taintTracker->isTainted($args[0]->value, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('cmdi.artisan_call'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.artisan_call.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkNewProcess(
|
||||
Node\Expr\New_ $node,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
|
||||
if (!str_contains($className, 'Process')) return;
|
||||
|
||||
$args = $this->getArguments($node);
|
||||
if (empty($args)) return;
|
||||
|
||||
$commandArg = $args[0]->value;
|
||||
|
||||
if (!($commandArg instanceof Node\Expr\Array_)) {
|
||||
if ($taintTracker->isTainted($commandArg, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('cmdi.process_tainted'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.process_tainted.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
} elseif ($commandArg instanceof Node\Expr\Array_) {
|
||||
foreach ($commandArg->items as $item) {
|
||||
if ($item && $taintTracker->isTainted($item->value, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('cmdi.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('cmdi.process_args'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('cmdi.process_args.rec'),
|
||||
[],
|
||||
'CWE-78',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function containsConcatenation(Node $node): bool
|
||||
{
|
||||
return $node instanceof Node\Expr\BinaryOp\Concat || $node instanceof Node\Scalar\Encapsed;
|
||||
}
|
||||
|
||||
private function hasTaintedPart(Node $node, TaintTracker $taintTracker, string $filePath): bool
|
||||
{
|
||||
if ($node instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $taintTracker->isTainted($node->left, $filePath)
|
||||
|| $taintTracker->isTainted($node->right, $filePath)
|
||||
|| $this->hasTaintedPart($node->left, $taintTracker, $filePath)
|
||||
|| $this->hasTaintedPart($node->right, $taintTracker, $filePath);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Scalar\Encapsed) {
|
||||
foreach ($node->parts as $part) {
|
||||
if ($taintTracker->isTainted($part, $filePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $taintTracker->isTainted($node, $filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command expression is properly sanitized
|
||||
*
|
||||
* @param Node $expr The expression to analyze
|
||||
* @param TaintTracker $taintTracker The taint tracker
|
||||
* @param string $filePath Current file path
|
||||
* @param int $depth Recursion depth to prevent infinite loops
|
||||
* @return bool True if the expression is safely sanitized
|
||||
*/
|
||||
private function isCommandExpressionSanitized(Node $expr, TaintTracker $taintTracker, string $filePath, int $depth = 0): bool
|
||||
{
|
||||
// Prevent infinite recursion
|
||||
if ($depth > 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// String literals are safe
|
||||
if ($expr instanceof Node\Scalar\String_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Numeric literals are safe
|
||||
if ($expr instanceof Node\Scalar\LNumber || $expr instanceof Node\Scalar\DNumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Boolean/null constants are safe
|
||||
if ($expr instanceof Node\Expr\ConstFetch) {
|
||||
$name = strtolower($expr->name->toString());
|
||||
return in_array($name, ['true', 'false', 'null']);
|
||||
}
|
||||
|
||||
// Type casts to int/float are safe
|
||||
if ($expr instanceof Node\Expr\Cast\Int_
|
||||
|| $expr instanceof Node\Expr\Cast\Double
|
||||
|| $expr instanceof Node\Expr\Cast\Bool_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Function calls - check if they sanitize commands
|
||||
if ($expr instanceof Node\Expr\FuncCall) {
|
||||
return $this->isFunctionCallCommandSafe($expr, $taintTracker, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Method calls
|
||||
if ($expr instanceof Node\Expr\MethodCall) {
|
||||
return $this->isMethodCallCommandSafe($expr, $taintTracker, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Concatenation - needs careful analysis
|
||||
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
|
||||
// For command concatenation, we need both parts to be safe
|
||||
// escapeshellcmd() on left + escapeshellarg() on right is a valid pattern
|
||||
return $this->isCommandConcatenationSafe($expr, $taintTracker, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Encapsed string - all variable parts must be sanitized
|
||||
if ($expr instanceof Node\Scalar\Encapsed) {
|
||||
foreach ($expr->parts as $part) {
|
||||
if (!$part instanceof Node\Scalar\EncapsedStringPart) {
|
||||
if (!$this->isCommandExpressionSanitized($part, $taintTracker, $filePath, $depth + 1)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ternary - both branches must be safe
|
||||
if ($expr instanceof Node\Expr\Ternary) {
|
||||
$ifSafe = $expr->if
|
||||
? $this->isCommandExpressionSanitized($expr->if, $taintTracker, $filePath, $depth + 1)
|
||||
: $this->isCommandExpressionSanitized($expr->cond, $taintTracker, $filePath, $depth + 1);
|
||||
$elseSafe = $this->isCommandExpressionSanitized($expr->else, $taintTracker, $filePath, $depth + 1);
|
||||
return $ifSafe && $elseSafe;
|
||||
}
|
||||
|
||||
// Null coalesce
|
||||
if ($expr instanceof Node\Expr\BinaryOp\Coalesce) {
|
||||
return $this->isCommandExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1)
|
||||
&& $this->isCommandExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
|
||||
}
|
||||
|
||||
// Parentheses
|
||||
if ($expr instanceof Node\Expr\Parenthesized) {
|
||||
return $this->isCommandExpressionSanitized($expr->expr, $taintTracker, $filePath, $depth + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a function call produces command-safe output
|
||||
*/
|
||||
private function isFunctionCallCommandSafe(Node\Expr\FuncCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
|
||||
{
|
||||
$funcName = $this->getCallName($expr);
|
||||
|
||||
if ($funcName === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known command sanitizer functions
|
||||
if (in_array($funcName, self::COMMAND_SANITIZERS)) {
|
||||
// Check if arguments don't contain sanitizer-breaking patterns
|
||||
$args = $this->getArguments($expr);
|
||||
if (!empty($args) && !$this->containsSanitizerBreaker($args[0]->value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitizer-breaking functions are NEVER safe
|
||||
if (in_array($funcName, self::COMMAND_SANITIZER_BREAKERS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Analyze user-defined functions
|
||||
return $this->analyzeUserFunctionForCommandSafety($funcName, $expr->args, $taintTracker, $filePath, $depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method call produces command-safe output
|
||||
*/
|
||||
private function isMethodCallCommandSafe(Node\Expr\MethodCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
|
||||
{
|
||||
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
|
||||
|
||||
if ($methodName === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sanitizer-breaking methods
|
||||
if (in_array($methodName, self::COMMAND_SANITIZER_BREAKERS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Chained calls where inner is escapeshellarg/escapeshellcmd
|
||||
if ($expr->var instanceof Node\Expr\FuncCall) {
|
||||
$innerFunc = $this->getCallName($expr->var);
|
||||
if ($innerFunc && in_array($innerFunc, self::COMMAND_SANITIZERS)) {
|
||||
// Check if this method doesn't break sanitization
|
||||
if (!in_array($methodName, self::COMMAND_SANITIZER_BREAKERS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command concatenation is safe
|
||||
* Valid patterns:
|
||||
* - escapeshellcmd($cmd) . ' ' . escapeshellarg($arg)
|
||||
* - 'literal' . escapeshellarg($arg)
|
||||
*/
|
||||
private function isCommandConcatenationSafe(Node\Expr\BinaryOp\Concat $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
|
||||
{
|
||||
// Both parts must be individually safe
|
||||
$leftSafe = $this->isCommandExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1);
|
||||
$rightSafe = $this->isCommandExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
|
||||
|
||||
return $leftSafe && $rightSafe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a user-defined function for command safety
|
||||
*/
|
||||
private function analyzeUserFunctionForCommandSafety(
|
||||
string $funcName,
|
||||
array $args,
|
||||
TaintTracker $taintTracker,
|
||||
string $filePath,
|
||||
int $depth
|
||||
): bool {
|
||||
// Check cache
|
||||
$cacheKey = $funcName . ':cmd_safe';
|
||||
if (isset($this->functionSanitizeCache[$cacheKey])) {
|
||||
return $this->functionSanitizeCache[$cacheKey];
|
||||
}
|
||||
|
||||
$callGraph = $this->getCallGraphFromTracker($taintTracker);
|
||||
|
||||
if ($callGraph === null) {
|
||||
$this->functionSanitizeCache[$cacheKey] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find function definition
|
||||
$definition = $callGraph['definitions'][$funcName] ?? null;
|
||||
|
||||
if ($definition === null) {
|
||||
foreach ($callGraph['classMethods'] ?? [] as $name => $def) {
|
||||
if (str_ends_with($name, "::{$funcName}")) {
|
||||
$definition = $def;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($definition === null || !isset($definition['node'])) {
|
||||
$this->functionSanitizeCache[$cacheKey] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Analyze return statements
|
||||
$result = $this->analyzeReturnStatementsForCommandSafety($definition['node'], $depth + 1);
|
||||
$this->functionSanitizeCache[$cacheKey] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze return statements in a function for command safety
|
||||
*/
|
||||
private function analyzeReturnStatementsForCommandSafety(Node $functionNode, int $depth): bool
|
||||
{
|
||||
if ($depth > 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmts = $functionNode->stmts ?? [];
|
||||
$returnResults = [];
|
||||
$hasSanitizerBreaker = false;
|
||||
|
||||
foreach ($stmts as $stmt) {
|
||||
$result = $this->analyzeStatementForCommandSafety($stmt, $depth, $hasSanitizerBreaker);
|
||||
if ($result !== null) {
|
||||
$returnResults[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasSanitizerBreaker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($returnResults)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !in_array(false, $returnResults, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a statement for command safety
|
||||
*/
|
||||
private function analyzeStatementForCommandSafety(Node $stmt, int $depth, bool &$hasSanitizerBreaker): ?bool
|
||||
{
|
||||
if ($stmt instanceof Node\Stmt\Return_ && $stmt->expr !== null) {
|
||||
return $this->isReturnExpressionCommandSafe($stmt->expr, $depth);
|
||||
}
|
||||
|
||||
if ($stmt instanceof Node\Stmt\Expression) {
|
||||
if ($this->containsSanitizerBreaker($stmt->expr)) {
|
||||
$hasSanitizerBreaker = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check nested statements
|
||||
$childStmts = [];
|
||||
if (isset($stmt->stmts) && is_array($stmt->stmts)) {
|
||||
$childStmts = array_merge($childStmts, $stmt->stmts);
|
||||
}
|
||||
if (isset($stmt->else) && isset($stmt->else->stmts)) {
|
||||
$childStmts = array_merge($childStmts, $stmt->else->stmts);
|
||||
}
|
||||
if (isset($stmt->elseifs)) {
|
||||
foreach ($stmt->elseifs as $elseif) {
|
||||
if (isset($elseif->stmts)) {
|
||||
$childStmts = array_merge($childStmts, $elseif->stmts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($childStmts as $childStmt) {
|
||||
$result = $this->analyzeStatementForCommandSafety($childStmt, $depth, $hasSanitizerBreaker);
|
||||
if ($result !== null) {
|
||||
$results[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($results)) {
|
||||
return !in_array(false, $results, true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a return expression is command-safe
|
||||
*/
|
||||
private function isReturnExpressionCommandSafe(Node $expr, int $depth): bool
|
||||
{
|
||||
// Direct command sanitizer function call
|
||||
if ($expr instanceof Node\Expr\FuncCall) {
|
||||
$funcName = $this->getCallName($expr);
|
||||
if ($funcName && in_array($funcName, self::COMMAND_SANITIZERS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric literal
|
||||
if ($expr instanceof Node\Scalar\LNumber || $expr instanceof Node\Scalar\DNumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Type cast to int/float
|
||||
if ($expr instanceof Node\Expr\Cast\Int_ || $expr instanceof Node\Expr\Cast\Double) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// String literal
|
||||
if ($expr instanceof Node\Scalar\String_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Concatenation where all parts are safe
|
||||
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $this->isReturnExpressionCommandSafe($expr->left, $depth)
|
||||
&& $this->isReturnExpressionCommandSafe($expr->right, $depth);
|
||||
}
|
||||
|
||||
// Ternary where both branches are safe
|
||||
if ($expr instanceof Node\Expr\Ternary) {
|
||||
$ifExpr = $expr->if ?? $expr->cond;
|
||||
return $this->isReturnExpressionCommandSafe($ifExpr, $depth)
|
||||
&& $this->isReturnExpressionCommandSafe($expr->else, $depth);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an expression contains sanitizer-breaking function calls
|
||||
*/
|
||||
private function containsSanitizerBreaker(Node $expr): bool
|
||||
{
|
||||
if ($expr instanceof Node\Expr\FuncCall) {
|
||||
$funcName = $this->getCallName($expr);
|
||||
if ($funcName && in_array($funcName, self::COMMAND_SANITIZER_BREAKERS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($expr instanceof Node\Expr\MethodCall) {
|
||||
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
|
||||
if ($methodName && in_array($methodName, self::COMMAND_SANITIZER_BREAKERS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check child nodes
|
||||
foreach ($expr->getSubNodeNames() as $name) {
|
||||
$subNode = $expr->{$name};
|
||||
if ($subNode instanceof Node) {
|
||||
if ($this->containsSanitizerBreaker($subNode)) {
|
||||
return true;
|
||||
}
|
||||
} elseif (is_array($subNode)) {
|
||||
foreach ($subNode as $item) {
|
||||
if ($item instanceof Node && $this->containsSanitizerBreaker($item)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if concatenation has tainted unsanitized parts
|
||||
*/
|
||||
private function hasTaintedUnsanitizedPart(Node $node, TaintTracker $taintTracker, string $filePath): bool
|
||||
{
|
||||
if ($node instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $this->hasTaintedUnsanitizedPart($node->left, $taintTracker, $filePath)
|
||||
|| $this->hasTaintedUnsanitizedPart($node->right, $taintTracker, $filePath);
|
||||
}
|
||||
|
||||
// Check if this part is sanitized
|
||||
if ($this->isCommandExpressionSanitized($node, $taintTracker, $filePath, 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Scalar\Encapsed) {
|
||||
foreach ($node->parts as $part) {
|
||||
if (!$part instanceof Node\Scalar\EncapsedStringPart) {
|
||||
if ($taintTracker->isTainted($part, $filePath) &&
|
||||
!$this->isCommandExpressionSanitized($part, $taintTracker, $filePath, 0)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return $taintTracker->isTainted($node, $filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the call graph from the taint tracker using reflection
|
||||
*/
|
||||
private function getCallGraphFromTracker(TaintTracker $taintTracker): ?array
|
||||
{
|
||||
try {
|
||||
$reflection = new \ReflectionClass($taintTracker);
|
||||
$property = $reflection->getProperty('callGraph');
|
||||
$property->setAccessible(true);
|
||||
return $property->getValue($taintTracker);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
697
src/Rules/CsrfSessionRule.php
Normal file
697
src/Rules/CsrfSessionRule.php
Normal file
@@ -0,0 +1,697 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Rules;
|
||||
|
||||
use PhpParser\Node;
|
||||
use SecurityLinter\Analyzer\TaintTracker;
|
||||
use SecurityLinter\Report\Vulnerability;
|
||||
|
||||
/**
|
||||
* Detects CSRF and Session Security vulnerabilities
|
||||
*
|
||||
* Checks for:
|
||||
* - Missing CSRF protection in forms
|
||||
* - Insecure session configuration
|
||||
* - Missing session regeneration
|
||||
* - Cookie security flags
|
||||
*/
|
||||
class CsrfSessionRule extends BaseRule
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->msg('csrf.name');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Detects CSRF and Session Security issues';
|
||||
}
|
||||
|
||||
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
|
||||
{
|
||||
$vulnerabilities = [];
|
||||
|
||||
// Check if it's a Blade template
|
||||
if (str_ends_with($filePath, '.blade.php')) {
|
||||
$vulnerabilities = array_merge(
|
||||
$vulnerabilities,
|
||||
$this->analyzeBladeTemplate($filePath)
|
||||
);
|
||||
}
|
||||
|
||||
// Analyze PHP code
|
||||
$this->traverse($ast, function (Node $node) use ($filePath, &$vulnerabilities) {
|
||||
// Check function calls
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$this->checkFunctionCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check method calls
|
||||
if ($node instanceof Node\Expr\MethodCall) {
|
||||
$this->checkMethodCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check static calls
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
$this->checkStaticCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check array access (session config)
|
||||
if ($node instanceof Node\Expr\ArrayDimFetch) {
|
||||
$this->checkArrayAccess($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return $vulnerabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze Blade template for CSRF
|
||||
*/
|
||||
private function analyzeBladeTemplate(string $filePath): array
|
||||
{
|
||||
$vulnerabilities = [];
|
||||
$content = file_get_contents($filePath);
|
||||
|
||||
// Find all forms
|
||||
preg_match_all('/<form[^>]*>(.*?)<\/form>/si', $content, $formMatches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($formMatches as $formMatch) {
|
||||
$formTag = $formMatch[0][0];
|
||||
$formContent = $formMatch[1][0] ?? '';
|
||||
$formOffset = $formMatch[0][1];
|
||||
$line = $this->getLineFromOffset($content, $formOffset);
|
||||
|
||||
// Check if form has method POST/PUT/DELETE/PATCH
|
||||
$needsCsrf = $this->formNeedsCsrf($formTag);
|
||||
|
||||
if ($needsCsrf) {
|
||||
// Check for CSRF token
|
||||
$hasCsrf = $this->hasCsrfToken($formContent);
|
||||
|
||||
if (!$hasCsrf) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('csrf.missing_token'),
|
||||
$filePath,
|
||||
$line,
|
||||
null,
|
||||
$this->msg('csrf.missing_token.rec'),
|
||||
[],
|
||||
'CWE-352',
|
||||
'A8:2017-CSRF'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for @method directive when using PUT/PATCH/DELETE
|
||||
if ($this->formNeedsMethodSpoofing($formTag)) {
|
||||
if (!$this->hasMethodField($formContent)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('csrf.missing_method'),
|
||||
$filePath,
|
||||
$line,
|
||||
null,
|
||||
$this->msg('csrf.missing_method.rec'),
|
||||
[],
|
||||
'CWE-352',
|
||||
'A8:2017-CSRF'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for AJAX without CSRF header setup
|
||||
if (preg_match('/\$\.ajax|fetch\s*\(|axios/', $content)) {
|
||||
if (!preg_match('/X-CSRF-TOKEN|csrf[_-]?token/i', $content)) {
|
||||
// Find the line
|
||||
preg_match('/\$\.ajax|fetch\s*\(|axios/', $content, $match, PREG_OFFSET_CAPTURE);
|
||||
$line = isset($match[0][1]) ? $this->getLineFromOffset($content, $match[0][1]) : 1;
|
||||
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('csrf.ajax_no_token'),
|
||||
$filePath,
|
||||
$line,
|
||||
null,
|
||||
$this->msg('csrf.ajax_no_token.rec'),
|
||||
[],
|
||||
'CWE-352',
|
||||
'A8:2017-CSRF'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $vulnerabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check function calls for session issues
|
||||
*/
|
||||
private function checkFunctionCall(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$funcName = $this->getCallName($node);
|
||||
|
||||
if ($funcName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check session_start() without secure settings
|
||||
if ($funcName === 'session_start') {
|
||||
$this->checkSessionStart($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check setcookie() for secure flags
|
||||
if ($funcName === 'setcookie') {
|
||||
$this->checkSetCookie($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check for session_regenerate_id
|
||||
if ($funcName === 'session_regenerate_id') {
|
||||
// Check if delete_old_session is true
|
||||
$args = $this->getArguments($node);
|
||||
if (empty($args)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('session.fixation'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.fixation.rec'),
|
||||
[],
|
||||
'CWE-384',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
} else {
|
||||
$arg = $args[0]->value;
|
||||
if ($arg instanceof Node\Expr\ConstFetch) {
|
||||
$val = strtolower($arg->name->toString());
|
||||
if ($val === 'false') {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('session.fixation_false'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.fixation_false.rec'),
|
||||
[],
|
||||
'CWE-384',
|
||||
'A2:2017-Broken Authentication'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check ini_set for session settings
|
||||
if ($funcName === 'ini_set') {
|
||||
$this->checkIniSet($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check session_start settings
|
||||
*/
|
||||
private function checkSessionStart(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
// If no options provided, flag for review
|
||||
if (empty($args)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('session.no_options'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.no_options.rec'),
|
||||
[],
|
||||
'CWE-614',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check options array
|
||||
$optionsArg = $args[0]->value;
|
||||
if ($optionsArg instanceof Node\Expr\Array_) {
|
||||
$options = $this->extractArrayOptions($optionsArg);
|
||||
|
||||
// Check for missing HttpOnly
|
||||
if (!isset($options['cookie_httponly']) || !$options['cookie_httponly']) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('session.no_httponly'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.no_httponly.rec'),
|
||||
[],
|
||||
'CWE-1004',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for missing Secure flag
|
||||
if (!isset($options['cookie_secure'])) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('session.no_secure'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.no_secure.rec'),
|
||||
[],
|
||||
'CWE-614',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for missing SameSite
|
||||
if (!isset($options['cookie_samesite'])) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('session.no_samesite'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.no_samesite.rec'),
|
||||
[],
|
||||
'CWE-352',
|
||||
'A8:2017-CSRF'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check setcookie for security flags
|
||||
*/
|
||||
private function checkSetCookie(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if (count($args) < 3) {
|
||||
return; // Not enough arguments to analyze
|
||||
}
|
||||
|
||||
// PHP 7.3+ uses options array as last argument
|
||||
// Older style: setcookie(name, value, expire, path, domain, secure, httponly)
|
||||
|
||||
$lastArg = end($args);
|
||||
if ($lastArg && $lastArg->value instanceof Node\Expr\Array_) {
|
||||
// Modern options array style
|
||||
$options = $this->extractArrayOptions($lastArg->value);
|
||||
|
||||
if (!isset($options['httponly']) || !$options['httponly']) {
|
||||
$this->addCookieVulnerability($vulnerabilities, 'HttpOnly', $node, $filePath);
|
||||
}
|
||||
|
||||
if (!isset($options['secure'])) {
|
||||
$this->addCookieVulnerability($vulnerabilities, 'Secure', $node, $filePath);
|
||||
}
|
||||
|
||||
if (!isset($options['samesite'])) {
|
||||
$this->addCookieVulnerability($vulnerabilities, 'SameSite', $node, $filePath);
|
||||
}
|
||||
} else {
|
||||
// Legacy style - check 7th argument (httponly)
|
||||
if (count($args) < 7) {
|
||||
$this->addCookieVulnerability($vulnerabilities, 'HttpOnly', $node, $filePath);
|
||||
} else {
|
||||
$httponlyArg = $args[6]->value ?? null;
|
||||
if ($httponlyArg instanceof Node\Expr\ConstFetch) {
|
||||
if (strtolower($httponlyArg->name->toString()) === 'false') {
|
||||
$this->addCookieVulnerability($vulnerabilities, 'HttpOnly', $node, $filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 6th argument (secure)
|
||||
if (count($args) < 6) {
|
||||
$this->addCookieVulnerability($vulnerabilities, 'Secure', $node, $filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add cookie vulnerability
|
||||
*/
|
||||
private function addCookieVulnerability(
|
||||
array &$vulnerabilities,
|
||||
string $flag,
|
||||
Node $node,
|
||||
string $filePath
|
||||
): void {
|
||||
$messages = [
|
||||
'HttpOnly' => [
|
||||
'msg' => 'cookie.no_httponly',
|
||||
'rec' => 'cookie.no_httponly.rec',
|
||||
'cwe' => 'CWE-1004',
|
||||
],
|
||||
'Secure' => [
|
||||
'msg' => 'cookie.no_secure',
|
||||
'rec' => 'cookie.no_secure.rec',
|
||||
'cwe' => 'CWE-614',
|
||||
],
|
||||
'SameSite' => [
|
||||
'msg' => 'cookie.no_samesite',
|
||||
'rec' => 'cookie.no_samesite.rec',
|
||||
'cwe' => 'CWE-352',
|
||||
],
|
||||
];
|
||||
|
||||
$info = $messages[$flag] ?? $messages['HttpOnly'];
|
||||
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg($info['msg']),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg($info['rec']),
|
||||
[],
|
||||
$info['cwe'],
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check ini_set for session settings
|
||||
*/
|
||||
private function checkIniSet(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if (count($args) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settingArg = $args[0]->value;
|
||||
$valueArg = $args[1]->value;
|
||||
|
||||
if (!($settingArg instanceof Node\Scalar\String_)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = $settingArg->value;
|
||||
|
||||
// Check for insecure session settings
|
||||
$insecureSettings = [
|
||||
'session.cookie_httponly' => ['0', 'false', ''],
|
||||
'session.cookie_secure' => ['0', 'false'],
|
||||
'session.use_only_cookies' => ['0', 'false'],
|
||||
'session.use_strict_mode' => ['0', 'false'],
|
||||
];
|
||||
|
||||
if (isset($insecureSettings[$setting])) {
|
||||
$insecureValues = $insecureSettings[$setting];
|
||||
$value = '';
|
||||
|
||||
if ($valueArg instanceof Node\Scalar\String_) {
|
||||
$value = strtolower($valueArg->value);
|
||||
} elseif ($valueArg instanceof Node\Scalar\LNumber) {
|
||||
$value = (string) $valueArg->value;
|
||||
} elseif ($valueArg instanceof Node\Expr\ConstFetch) {
|
||||
$value = strtolower($valueArg->name->toString());
|
||||
}
|
||||
|
||||
if (in_array($value, $insecureValues)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('session.insecure_ini', ['setting' => $setting, 'value' => $value]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.insecure_ini.rec', ['setting' => $setting]),
|
||||
[],
|
||||
'CWE-614',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check method calls
|
||||
*/
|
||||
private function checkMethodCall(
|
||||
Node\Expr\MethodCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$methodName = $this->getCallName($node);
|
||||
|
||||
// Check for session()->regenerate()
|
||||
if ($methodName === 'regenerate') {
|
||||
// This is good practice, no vulnerability
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for withoutMiddleware('csrf')
|
||||
if ($methodName === 'withoutMiddleware') {
|
||||
$args = $this->getArguments($node);
|
||||
foreach ($args as $arg) {
|
||||
if ($arg->value instanceof Node\Scalar\String_) {
|
||||
$value = strtolower($arg->value->value);
|
||||
if (str_contains($value, 'csrf') || str_contains($value, 'verifycsrftoken')) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('csrf.disabled'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('csrf.disabled.rec'),
|
||||
[],
|
||||
'CWE-352',
|
||||
'A8:2017-CSRF'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check static calls
|
||||
*/
|
||||
private function checkStaticCall(
|
||||
Node\Expr\StaticCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
|
||||
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
|
||||
|
||||
// Check Session facade usage
|
||||
if (str_contains($className, 'Session')) {
|
||||
if ($methodName === 'put' || $methodName === 'set') {
|
||||
// Check for sensitive data in session
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $args[0]->value instanceof Node\Scalar\String_) {
|
||||
$key = strtolower($args[0]->value->value);
|
||||
$sensitiveKeys = ['password', 'credit_card', 'ssn', 'secret'];
|
||||
|
||||
foreach ($sensitiveKeys as $sensitive) {
|
||||
if (str_contains($key, $sensitive)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('session.sensitive_data', ['key' => $key]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.sensitive_data.rec'),
|
||||
[],
|
||||
'CWE-922',
|
||||
'A3:2017-Sensitive Data Exposure'
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check array access
|
||||
*/
|
||||
private function checkArrayAccess(
|
||||
Node\Expr\ArrayDimFetch $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
// Check $_SESSION direct access
|
||||
if ($node->var instanceof Node\Expr\Variable) {
|
||||
$varName = $this->getVariableName($node->var);
|
||||
if ($varName === '_SESSION') {
|
||||
// Check the key
|
||||
if ($node->dim instanceof Node\Scalar\String_) {
|
||||
$key = strtolower($node->dim->value);
|
||||
$sensitiveKeys = ['password', 'credit_card', 'ssn'];
|
||||
|
||||
foreach ($sensitiveKeys as $sensitive) {
|
||||
if (str_contains($key, $sensitive)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('csrf.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('session.sensitive_data', ['key' => $key]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('session.sensitive_data.rec'),
|
||||
[],
|
||||
'CWE-922',
|
||||
'A3:2017-Sensitive Data Exposure'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if form needs CSRF protection
|
||||
*/
|
||||
private function formNeedsCsrf(string $formTag): bool
|
||||
{
|
||||
// Check for method attribute
|
||||
if (preg_match('/method\s*=\s*["\']?(post|put|patch|delete)/i', $formTag)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No method or GET - doesn't need CSRF
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if form content has CSRF token
|
||||
*/
|
||||
private function hasCsrfToken(string $content): bool
|
||||
{
|
||||
// Check for @csrf directive
|
||||
if (preg_match('/@csrf\b/', $content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for csrf_field()
|
||||
if (preg_match('/csrf_field\s*\(\s*\)/', $content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for hidden input with _token
|
||||
if (preg_match('/<input[^>]*name\s*=\s*["\']_token["\'][^>]*>/i', $content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for {{ csrf_token() }}
|
||||
if (preg_match('/\{\{\s*csrf_token\s*\(\s*\)\s*\}\}/', $content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if form needs method spoofing
|
||||
*/
|
||||
private function formNeedsMethodSpoofing(string $formTag): bool
|
||||
{
|
||||
// HTML forms only support GET and POST
|
||||
// PUT/PATCH/DELETE need spoofing
|
||||
return preg_match('/method\s*=\s*["\']?(put|patch|delete)/i', $formTag) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if form has method field
|
||||
*/
|
||||
private function hasMethodField(string $content): bool
|
||||
{
|
||||
if (preg_match('/@method\s*\(/', $content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/method_field\s*\(/', $content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/<input[^>]*name\s*=\s*["\']_method["\'][^>]*>/i', $content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract options from array node
|
||||
*/
|
||||
private function extractArrayOptions(Node\Expr\Array_ $node): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach ($node->items as $item) {
|
||||
if ($item === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = null;
|
||||
if ($item->key instanceof Node\Scalar\String_) {
|
||||
$key = $item->key->value;
|
||||
}
|
||||
|
||||
$value = null;
|
||||
if ($item->value instanceof Node\Scalar\String_) {
|
||||
$value = $item->value->value;
|
||||
} elseif ($item->value instanceof Node\Expr\ConstFetch) {
|
||||
$value = strtolower($item->value->name->toString()) === 'true';
|
||||
} elseif ($item->value instanceof Node\Scalar\LNumber) {
|
||||
$value = $item->value->value;
|
||||
}
|
||||
|
||||
if ($key !== null) {
|
||||
$options[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get line number from offset
|
||||
*/
|
||||
private function getLineFromOffset(string $content, int $offset): int
|
||||
{
|
||||
return substr_count(substr($content, 0, $offset), "\n") + 1;
|
||||
}
|
||||
}
|
||||
687
src/Rules/InsecureConfigRule.php
Normal file
687
src/Rules/InsecureConfigRule.php
Normal file
@@ -0,0 +1,687 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Rules;
|
||||
|
||||
use PhpParser\Node;
|
||||
use SecurityLinter\Analyzer\TaintTracker;
|
||||
use SecurityLinter\Report\Vulnerability;
|
||||
|
||||
/**
|
||||
* Detects Insecure Configuration vulnerabilities
|
||||
*
|
||||
* Checks for:
|
||||
* - Debug mode enabled in production
|
||||
* - Exposed .env files
|
||||
* - Insecure headers
|
||||
* - Missing security configurations
|
||||
* - Information disclosure
|
||||
*/
|
||||
class InsecureConfigRule extends BaseRule
|
||||
{
|
||||
/** @var array Laravel validation rule patterns */
|
||||
private const VALIDATION_RULE_PATTERNS = [
|
||||
'required', 'nullable', 'sometimes', 'present', 'filled',
|
||||
'string', 'integer', 'numeric', 'boolean', 'array', 'json',
|
||||
'email', 'url', 'uuid', 'ulid', 'ip', 'ipv4', 'ipv6',
|
||||
'date', 'date_format', 'before', 'after', 'timezone',
|
||||
'file', 'image', 'mimes', 'mimetypes',
|
||||
'min:', 'max:', 'size:', 'between:', 'digits:', 'digits_between:',
|
||||
'in:', 'not_in:', 'exists:', 'unique:', 'regex:', 'confirmed',
|
||||
'alpha', 'alpha_num', 'alpha_dash', 'active_url',
|
||||
'accepted', 'declined', 'prohibited', 'exclude',
|
||||
];
|
||||
|
||||
/** @var array Laravel cast type patterns */
|
||||
private const LARAVEL_CASTS = [
|
||||
'hashed', 'encrypted', 'datetime', 'date', 'timestamp',
|
||||
'boolean', 'bool', 'integer', 'int', 'real', 'float', 'double',
|
||||
'string', 'array', 'json', 'object', 'collection', 'immutable_date',
|
||||
'immutable_datetime', 'decimal:', 'AsStringable',
|
||||
];
|
||||
|
||||
/** @var array Patterns that indicate i18n/message keys */
|
||||
private const I18N_KEY_PATTERNS = [
|
||||
'/^[a-z_]+\.[a-z_]+/', // dot notation like "auth.password"
|
||||
'/^[a-z_]+:[a-z_]+/', // colon notation
|
||||
];
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->msg('config.name');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Detects Insecure Configuration and Information Disclosure';
|
||||
}
|
||||
|
||||
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
|
||||
{
|
||||
$vulnerabilities = [];
|
||||
|
||||
$this->traverse($ast, function (Node $node) use ($filePath, &$vulnerabilities) {
|
||||
// Check function calls
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$this->checkFunctionCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check static calls
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
$this->checkStaticCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check method calls
|
||||
if ($node instanceof Node\Expr\MethodCall) {
|
||||
$this->checkMethodCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check array definitions (config arrays)
|
||||
if ($node instanceof Node\Expr\Array_) {
|
||||
$this->checkConfigArray($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check return statements in config files
|
||||
if ($node instanceof Node\Stmt\Return_) {
|
||||
$this->checkConfigReturn($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return $vulnerabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check function calls for security issues
|
||||
*/
|
||||
private function checkFunctionCall(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$funcName = $this->getCallName($node);
|
||||
|
||||
if ($funcName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check phpinfo() - information disclosure
|
||||
if ($funcName === 'phpinfo') {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('config.phpinfo'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.phpinfo.rec'),
|
||||
[],
|
||||
'CWE-200',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
|
||||
// Check var_dump/print_r in production code
|
||||
if (in_array($funcName, ['var_dump', 'print_r', 'var_export', 'debug_print_backtrace'])) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('config.debug_output', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.debug_output.rec'),
|
||||
[],
|
||||
'CWE-200',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
|
||||
// Check error_reporting settings
|
||||
if ($funcName === 'error_reporting') {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args)) {
|
||||
$arg = $args[0]->value;
|
||||
if ($arg instanceof Node\Scalar\LNumber && $arg->value === -1) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('config.error_reporting'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.error_reporting.rec'),
|
||||
[],
|
||||
'CWE-209',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check ini_set for display_errors
|
||||
if ($funcName === 'ini_set') {
|
||||
$this->checkIniSet($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check header() for security headers
|
||||
if ($funcName === 'header') {
|
||||
$this->checkHeader($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check env() without default value
|
||||
if ($funcName === 'env') {
|
||||
$args = $this->getArguments($node);
|
||||
if (count($args) === 1) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('config.env_no_default'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.env_no_default.rec'),
|
||||
[],
|
||||
'CWE-1188',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dangerous deserialization
|
||||
if ($funcName === 'unserialize') {
|
||||
$this->checkUnserialize($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check ini_set calls
|
||||
*/
|
||||
private function checkIniSet(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if (count($args) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$settingArg = $args[0]->value;
|
||||
$valueArg = $args[1]->value;
|
||||
|
||||
if (!($settingArg instanceof Node\Scalar\String_)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = $settingArg->value;
|
||||
$value = $this->getScalarValue($valueArg);
|
||||
|
||||
// Check for dangerous settings
|
||||
$dangerousSettings = [
|
||||
'display_errors' => ['1', 'true', 'on'],
|
||||
'display_startup_errors' => ['1', 'true', 'on'],
|
||||
'expose_php' => ['1', 'true', 'on'],
|
||||
'allow_url_include' => ['1', 'true', 'on'],
|
||||
'allow_url_fopen' => ['1', 'true', 'on'],
|
||||
];
|
||||
|
||||
if (isset($dangerousSettings[$setting])) {
|
||||
if (in_array(strtolower((string)$value), $dangerousSettings[$setting])) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('config.insecure_ini', ['setting' => $setting, 'value' => $value]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.insecure_ini.rec', ['setting' => $setting]),
|
||||
[],
|
||||
'CWE-209',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check header() for security headers
|
||||
*/
|
||||
private function checkHeader(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if (empty($args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$headerArg = $args[0]->value;
|
||||
|
||||
if (!($headerArg instanceof Node\Scalar\String_)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$header = $headerArg->value;
|
||||
|
||||
// Check for X-Powered-By exposure
|
||||
if (stripos($header, 'X-Powered-By') !== false) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('config.header_powered_by'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.header_powered_by.rec'),
|
||||
[],
|
||||
'CWE-200',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Server header exposure
|
||||
if (stripos($header, 'Server:') !== false && !stripos($header, 'Server:$')) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_LOW,
|
||||
$this->msg('config.header_server'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.header_server.rec'),
|
||||
[],
|
||||
'CWE-200',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check unserialize for security
|
||||
*/
|
||||
private function checkUnserialize(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
if (empty($args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for allowed_classes option (PHP 7.0+)
|
||||
if (count($args) < 2) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('config.unserialize'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.unserialize.rec'),
|
||||
[],
|
||||
'CWE-502',
|
||||
'A8:2017-Insecure Deserialization'
|
||||
);
|
||||
} else {
|
||||
// Check if allowed_classes is properly set
|
||||
$optionsArg = $args[1]->value;
|
||||
if ($optionsArg instanceof Node\Expr\Array_) {
|
||||
$hasAllowedClasses = false;
|
||||
foreach ($optionsArg->items as $item) {
|
||||
if ($item && $item->key instanceof Node\Scalar\String_) {
|
||||
if ($item->key->value === 'allowed_classes') {
|
||||
$hasAllowedClasses = true;
|
||||
// Check if it's true (allows all classes)
|
||||
if ($item->value instanceof Node\Expr\ConstFetch) {
|
||||
if (strtolower($item->value->name->toString()) === 'true') {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('config.unserialize_true'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.unserialize_true.rec'),
|
||||
[],
|
||||
'CWE-502',
|
||||
'A8:2017-Insecure Deserialization'
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasAllowedClasses) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('config.unserialize_no_key'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.unserialize_no_key.rec'),
|
||||
[],
|
||||
'CWE-502',
|
||||
'A8:2017-Insecure Deserialization'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check static calls
|
||||
*/
|
||||
private function checkStaticCall(
|
||||
Node\Expr\StaticCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
|
||||
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
|
||||
|
||||
// Check Log facade for sensitive data
|
||||
if (str_contains($className, 'Log') && in_array($methodName, ['info', 'debug', 'warning', 'error'])) {
|
||||
$this->checkLogCall($node, $filePath, $vulnerabilities);
|
||||
}
|
||||
|
||||
// Check Config facade
|
||||
if (str_contains($className, 'Config') && $methodName === 'set') {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $args[0]->value instanceof Node\Scalar\String_) {
|
||||
$key = strtolower($args[0]->value->value);
|
||||
if (str_contains($key, 'debug') && count($args) >= 2) {
|
||||
$value = $this->getScalarValue($args[1]->value);
|
||||
if (in_array(strtolower((string)$value), ['true', '1'])) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('config.debug_mode'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.debug_mode.rec'),
|
||||
[],
|
||||
'CWE-489',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check log calls for sensitive data
|
||||
*/
|
||||
private function checkLogCall(
|
||||
Node\Expr\StaticCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
|
||||
foreach ($args as $arg) {
|
||||
// Check for variables with sensitive names
|
||||
if ($arg->value instanceof Node\Expr\Variable) {
|
||||
$varName = strtolower($this->getVariableName($arg->value));
|
||||
$sensitiveNames = ['password', 'secret', 'token', 'key', 'credit', 'ssn'];
|
||||
|
||||
foreach ($sensitiveNames as $sensitive) {
|
||||
if (str_contains($varName, $sensitive)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('config.sensitive_log', ['var' => $varName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.sensitive_log.rec'),
|
||||
[],
|
||||
'CWE-532',
|
||||
'A3:2017-Sensitive Data Exposure'
|
||||
);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for array with sensitive keys
|
||||
if ($arg->value instanceof Node\Expr\Array_) {
|
||||
foreach ($arg->value->items as $item) {
|
||||
if ($item && $item->key instanceof Node\Scalar\String_) {
|
||||
$key = strtolower($item->key->value);
|
||||
$sensitiveKeys = ['password', 'secret', 'token', 'api_key', 'credit_card'];
|
||||
|
||||
foreach ($sensitiveKeys as $sensitive) {
|
||||
if (str_contains($key, $sensitive)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('config.sensitive_log_key', ['key' => $key]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.sensitive_log_key.rec'),
|
||||
[],
|
||||
'CWE-532',
|
||||
'A3:2017-Sensitive Data Exposure'
|
||||
);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check method calls
|
||||
*/
|
||||
private function checkMethodCall(
|
||||
Node\Expr\MethodCall $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$methodName = $this->getCallName($node);
|
||||
|
||||
// Check for dd() and dump()
|
||||
if (in_array($methodName, ['dd', 'dump'])) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_MEDIUM,
|
||||
$this->msg('config.dd_dump', ['func' => $methodName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.dd_dump.rec'),
|
||||
[],
|
||||
'CWE-200',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check config array for insecure settings
|
||||
*/
|
||||
private function checkConfigArray(
|
||||
Node\Expr\Array_ $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
foreach ($node->items as $item) {
|
||||
if ($item === null || !($item->key instanceof Node\Scalar\String_)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalKey = $item->key->value;
|
||||
$key = strtolower($originalKey);
|
||||
$value = $this->getScalarValue($item->value);
|
||||
|
||||
// Check for debug settings
|
||||
if ($key === 'debug' && in_array(strtolower((string)$value), ['true', '1'])) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('config.debug_hardcoded'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.debug_hardcoded.rec'),
|
||||
[],
|
||||
'CWE-489',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for hardcoded secrets
|
||||
if (in_array($key, ['key', 'secret', 'password', 'api_key'])) {
|
||||
if ($item->value instanceof Node\Scalar\String_) {
|
||||
$val = $item->value->value;
|
||||
|
||||
// Skip i18n message keys
|
||||
if ($this->isI18nKey($originalKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip Laravel validation rules
|
||||
if ($this->isLaravelValidationRule($val)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip Laravel casts
|
||||
if ($this->isLaravelCast($val)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's not using env()
|
||||
if (!empty($val) && !str_starts_with($val, 'env(')) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('config.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('config.hardcoded_secret', ['key' => $key]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('config.hardcoded_secret.rec', ['key' => $key]),
|
||||
[],
|
||||
'CWE-798',
|
||||
'A6:2017-Security Misconfiguration'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks like a Laravel validation rule
|
||||
*/
|
||||
private function isLaravelValidationRule(string $value): bool
|
||||
{
|
||||
if (empty($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for pipe-separated rules
|
||||
if (str_contains($value, '|')) {
|
||||
$parts = explode('|', $value);
|
||||
foreach ($parts as $part) {
|
||||
$ruleName = explode(':', $part)[0];
|
||||
foreach (self::VALIDATION_RULE_PATTERNS as $pattern) {
|
||||
$patternName = rtrim($pattern, ':');
|
||||
if ($ruleName === $patternName || str_starts_with($part, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check single rule
|
||||
$ruleName = explode(':', $value)[0];
|
||||
foreach (self::VALIDATION_RULE_PATTERNS as $pattern) {
|
||||
$patternName = rtrim($pattern, ':');
|
||||
if ($ruleName === $patternName || str_starts_with($value, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value looks like a Laravel cast definition
|
||||
*/
|
||||
private function isLaravelCast(string $value): bool
|
||||
{
|
||||
if (empty($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::LARAVEL_CASTS as $cast) {
|
||||
if ($value === rtrim($cast, ':') || str_starts_with($value, $cast)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key is an i18n message key
|
||||
*/
|
||||
private function isI18nKey(string $key): bool
|
||||
{
|
||||
foreach (self::I18N_KEY_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check config file return statements
|
||||
*/
|
||||
private function checkConfigReturn(
|
||||
Node\Stmt\Return_ $node,
|
||||
string $filePath,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
// Only check files in config directory
|
||||
if (!str_contains($filePath, '/config/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If returning an array, it's checked by checkConfigArray
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scalar value from node
|
||||
*/
|
||||
private function getScalarValue(Node $node): mixed
|
||||
{
|
||||
if ($node instanceof Node\Scalar\String_) {
|
||||
return $node->value;
|
||||
}
|
||||
if ($node instanceof Node\Scalar\LNumber) {
|
||||
return $node->value;
|
||||
}
|
||||
if ($node instanceof Node\Expr\ConstFetch) {
|
||||
return $node->name->toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
1091
src/Rules/PathTraversalRule.php
Normal file
1091
src/Rules/PathTraversalRule.php
Normal file
File diff suppressed because it is too large
Load Diff
34
src/Rules/RuleInterface.php
Normal file
34
src/Rules/RuleInterface.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Rules;
|
||||
|
||||
use SecurityLinter\Analyzer\TaintTracker;
|
||||
use SecurityLinter\Report\Vulnerability;
|
||||
|
||||
/**
|
||||
* Interface for security rules
|
||||
*/
|
||||
interface RuleInterface
|
||||
{
|
||||
/**
|
||||
* Get the rule name
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Get the rule description
|
||||
*/
|
||||
public function getDescription(): string;
|
||||
|
||||
/**
|
||||
* Analyze AST and return vulnerabilities
|
||||
*
|
||||
* @param array $ast The PHP AST
|
||||
* @param string $filePath Path to the file being analyzed
|
||||
* @param TaintTracker $taintTracker Taint tracking for data flow analysis
|
||||
* @return Vulnerability[]
|
||||
*/
|
||||
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array;
|
||||
}
|
||||
885
src/Rules/SqlInjectionRule.php
Normal file
885
src/Rules/SqlInjectionRule.php
Normal file
@@ -0,0 +1,885 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter\Rules;
|
||||
|
||||
use PhpParser\Node;
|
||||
use PhpParser\ParserFactory;
|
||||
use SecurityLinter\Analyzer\TaintTracker;
|
||||
use SecurityLinter\Report\Vulnerability;
|
||||
|
||||
/**
|
||||
* Detects SQL Injection vulnerabilities
|
||||
*
|
||||
* This rule performs sophisticated analysis including:
|
||||
* - Detection of tainted input in SQL queries
|
||||
* - Recognition of SQL sanitizer functions (intval, PDO::quote, etc.)
|
||||
* - Detection of sanitizer-breaking patterns
|
||||
* - Recursive analysis of user-defined functions
|
||||
*/
|
||||
class SqlInjectionRule extends BaseRule
|
||||
{
|
||||
private const RAW_METHODS = [
|
||||
'whereRaw', 'orWhereRaw', 'havingRaw', 'orHavingRaw',
|
||||
'selectRaw', 'orderByRaw', 'groupByRaw',
|
||||
'raw', 'statement', 'unprepared',
|
||||
];
|
||||
|
||||
/** @var array SQL sanitizer functions */
|
||||
private const SQL_SANITIZERS = [
|
||||
// Type casting (safest)
|
||||
'intval', 'floatval', 'boolval',
|
||||
'abs', 'ceil', 'floor', 'round',
|
||||
// String escaping
|
||||
'mysqli_real_escape_string', 'mysql_real_escape_string',
|
||||
'pg_escape_string', 'pg_escape_literal', 'pg_escape_identifier',
|
||||
'sqlite_escape_string', 'sqlite3_escape_string',
|
||||
'addslashes', // Not recommended but does provide some protection
|
||||
// Validation
|
||||
'ctype_digit', 'ctype_alnum', 'ctype_alpha',
|
||||
'is_numeric', 'is_int', 'is_integer', 'is_float',
|
||||
// Filter
|
||||
'filter_var',
|
||||
];
|
||||
|
||||
/** @var array Methods that sanitize SQL */
|
||||
private const SQL_SANITIZER_METHODS = [
|
||||
'quote', 'escape', 'escapeString', 'quoteIdentifier',
|
||||
'real_escape_string', 'escape_string',
|
||||
];
|
||||
|
||||
/** @var array Functions/patterns that break SQL sanitization */
|
||||
private const SQL_SANITIZER_BREAKERS = [
|
||||
// These can undo escaping or reintroduce dangerous characters
|
||||
'stripslashes', 'stripcslashes',
|
||||
'html_entity_decode', 'htmlspecialchars_decode',
|
||||
'urldecode', 'rawurldecode',
|
||||
'base64_decode',
|
||||
'sprintf', // Can bypass sanitization in format strings
|
||||
];
|
||||
|
||||
/** @var array Cache for function analysis */
|
||||
private array $functionSanitizeCache = [];
|
||||
|
||||
/** @var TaintTracker|null */
|
||||
private ?TaintTracker $currentTaintTracker = null;
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->msg('sqli.name');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Detects SQL Injection vulnerabilities';
|
||||
}
|
||||
|
||||
public function analyze(array $ast, string $filePath, TaintTracker $taintTracker): array
|
||||
{
|
||||
$vulnerabilities = [];
|
||||
$this->currentTaintTracker = $taintTracker;
|
||||
|
||||
$this->traverse($ast, function (Node $node) use ($filePath, $taintTracker, &$vulnerabilities) {
|
||||
if ($node instanceof Node\Expr\StaticCall) {
|
||||
$this->checkStaticCall($node, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\MethodCall) {
|
||||
$this->checkMethodCall($node, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\FuncCall) {
|
||||
$this->checkFunctionCall($node, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Expr\Assign) {
|
||||
$this->checkAssignment($node, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return $vulnerabilities;
|
||||
}
|
||||
|
||||
private function checkStaticCall(
|
||||
Node\Expr\StaticCall $node,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$className = $node->class instanceof Node\Name ? $node->class->toString() : '';
|
||||
$methodName = $node->name instanceof Node\Identifier ? $node->name->toString() : '';
|
||||
|
||||
if (in_array($className, ['DB', 'Illuminate\\Support\\Facades\\DB'])) {
|
||||
if ($methodName === 'raw') {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $this->isTaintedSqlArg($args[0]->value, $taintTracker, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('sqli.db_raw'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.db_raw.rec'),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($methodName, ['select', 'insert', 'update', 'delete', 'statement', 'unprepared'])) {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $this->isTaintedSqlArg($args[0]->value, $taintTracker, $filePath)) {
|
||||
$severity = $methodName === 'unprepared'
|
||||
? Vulnerability::SEVERITY_CRITICAL
|
||||
: Vulnerability::SEVERITY_HIGH;
|
||||
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
$severity,
|
||||
$this->msg('sqli.db_query', ['method' => $methodName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.db_query.rec'),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($methodName, self::RAW_METHODS)) {
|
||||
$this->checkRawMethod($node, $methodName, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkRawMethod(
|
||||
Node $node,
|
||||
string $methodName,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$args = $this->getArguments($node);
|
||||
if (empty($args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$queryArg = $args[0]->value;
|
||||
$hasBindings = count($args) > 1;
|
||||
|
||||
if ($this->isTaintedSqlArg($queryArg, $taintTracker, $filePath)) {
|
||||
if (!$hasBindings || $this->containsConcatenation($queryArg)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('sqli.raw_method', ['method' => $methodName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.raw_method.rec', ['method' => $methodName]),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($methodName, ['orderByRaw', 'groupByRaw'])) {
|
||||
if ($this->containsUserInput($queryArg)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('sqli.order_by_raw', ['method' => $methodName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.order_by_raw.rec'),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkMethodCall(
|
||||
Node\Expr\MethodCall $node,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$methodName = $this->getCallName($node);
|
||||
|
||||
if (in_array($methodName, self::RAW_METHODS)) {
|
||||
$this->checkRawMethod($node, $methodName, $filePath, $taintTracker, $vulnerabilities);
|
||||
}
|
||||
|
||||
if (in_array($methodName, ['query', 'exec'])) {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $this->isTaintedSqlArg($args[0]->value, $taintTracker, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('sqli.pdo_query'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.pdo_query.rec'),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($methodName === 'prepare') {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args)) {
|
||||
$queryArg = $args[0]->value;
|
||||
if ($this->containsConcatenation($queryArg) && $this->isTaintedSqlArg($queryArg, $taintTracker, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('sqli.pdo_prepare'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.pdo_prepare.rec'),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($methodName, ['query', 'multi_query', 'real_query'])) {
|
||||
$args = $this->getArguments($node);
|
||||
if (!empty($args) && $this->isTaintedSqlArg($args[0]->value, $taintTracker, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('sqli.mysqli', ['method' => $methodName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.mysqli.rec'),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkFunctionCall(
|
||||
Node\Expr\FuncCall $node,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$funcName = $this->getCallName($node);
|
||||
|
||||
$dangerousFunctions = [
|
||||
'mysqli_query', 'mysqli_multi_query', 'mysqli_real_query',
|
||||
'pg_query', 'pg_query_params', 'pg_send_query',
|
||||
'sqlite_query', 'sqlite_exec',
|
||||
];
|
||||
|
||||
if (in_array($funcName, $dangerousFunctions)) {
|
||||
$args = $this->getArguments($node);
|
||||
$queryArgIndex = 1;
|
||||
if (isset($args[$queryArgIndex])) {
|
||||
$queryArg = $args[$queryArgIndex]->value;
|
||||
if ($this->isTaintedSqlArg($queryArg, $taintTracker, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
Vulnerability::SEVERITY_CRITICAL,
|
||||
$this->msg('sqli.func', ['func' => $funcName]),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.func.rec'),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkAssignment(
|
||||
Node\Expr\Assign $node,
|
||||
string $filePath,
|
||||
TaintTracker $taintTracker,
|
||||
array &$vulnerabilities
|
||||
): void {
|
||||
$varName = '';
|
||||
if ($node->var instanceof Node\Expr\Variable && is_string($node->var->name)) {
|
||||
$varName = strtolower($node->var->name);
|
||||
}
|
||||
|
||||
$sqlVarNames = ['sql', 'query', 'stmt', 'statement'];
|
||||
if (!in_array($varName, $sqlVarNames)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->containsConcatenation($node->expr) && $taintTracker->isTainted($node->expr, $filePath)) {
|
||||
$vulnerabilities[] = $this->createVulnerability(
|
||||
$this->msg('sqli.name'),
|
||||
Vulnerability::SEVERITY_HIGH,
|
||||
$this->msg('sqli.string_concat'),
|
||||
$filePath,
|
||||
$node->getStartLine(),
|
||||
$node,
|
||||
$this->msg('sqli.string_concat.rec'),
|
||||
[],
|
||||
'CWE-89',
|
||||
'A1:2017-Injection'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a SQL argument is tainted (contains unsanitized user input)
|
||||
*
|
||||
* This method performs recursive analysis to detect:
|
||||
* 1. Direct tainted variables
|
||||
* 2. Properly sanitized input (intval, mysqli_real_escape_string, etc.)
|
||||
* 3. Sanitizer-breaking patterns
|
||||
* 4. User-defined functions that sanitize or don't sanitize
|
||||
*/
|
||||
private function isTaintedSqlArg(Node $arg, TaintTracker $taintTracker, string $filePath): bool
|
||||
{
|
||||
// String literals are safe
|
||||
if ($arg instanceof Node\Scalar\String_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Numeric literals are safe
|
||||
if ($arg instanceof Node\Scalar\LNumber || $arg instanceof Node\Scalar\DNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the expression is properly sanitized
|
||||
if ($this->isSqlExpressionSanitized($arg, $taintTracker, $filePath, 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Direct taint check
|
||||
if ($taintTracker->isTainted($arg, $filePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check concatenation for tainted parts
|
||||
if ($this->containsConcatenation($arg)) {
|
||||
return $this->hasTaintedUnsanitizedPart($arg, $taintTracker, $filePath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a SQL expression is properly sanitized
|
||||
*
|
||||
* @param Node $expr The expression to analyze
|
||||
* @param TaintTracker $taintTracker The taint tracker
|
||||
* @param string $filePath Current file path
|
||||
* @param int $depth Recursion depth to prevent infinite loops
|
||||
* @return bool True if the expression is safely sanitized
|
||||
*/
|
||||
private function isSqlExpressionSanitized(Node $expr, TaintTracker $taintTracker, string $filePath, int $depth = 0): bool
|
||||
{
|
||||
// Prevent infinite recursion
|
||||
if ($depth > 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Numeric literals are always safe
|
||||
if ($expr instanceof Node\Scalar\LNumber || $expr instanceof Node\Scalar\DNumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// String literals are safe
|
||||
if ($expr instanceof Node\Scalar\String_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Boolean/null constants are safe
|
||||
if ($expr instanceof Node\Expr\ConstFetch) {
|
||||
$name = strtolower($expr->name->toString());
|
||||
return in_array($name, ['true', 'false', 'null']);
|
||||
}
|
||||
|
||||
// Type casts to int/float are safe
|
||||
if ($expr instanceof Node\Expr\Cast\Int_
|
||||
|| $expr instanceof Node\Expr\Cast\Double
|
||||
|| $expr instanceof Node\Expr\Cast\Bool_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Function calls - check if they sanitize SQL
|
||||
if ($expr instanceof Node\Expr\FuncCall) {
|
||||
return $this->isFunctionCallSqlSafe($expr, $taintTracker, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Method calls - check for sanitizer methods
|
||||
if ($expr instanceof Node\Expr\MethodCall) {
|
||||
return $this->isMethodCallSqlSafe($expr, $taintTracker, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Static method calls
|
||||
if ($expr instanceof Node\Expr\StaticCall) {
|
||||
return $this->isStaticCallSqlSafe($expr, $taintTracker, $filePath, $depth);
|
||||
}
|
||||
|
||||
// Concatenation - all parts must be safe
|
||||
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $this->isSqlExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1)
|
||||
&& $this->isSqlExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
|
||||
}
|
||||
|
||||
// Encapsed string - all parts must be safe
|
||||
if ($expr instanceof Node\Scalar\Encapsed) {
|
||||
foreach ($expr->parts as $part) {
|
||||
if (!$part instanceof Node\Scalar\EncapsedStringPart) {
|
||||
if (!$this->isSqlExpressionSanitized($part, $taintTracker, $filePath, $depth + 1)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ternary - both branches must be safe
|
||||
if ($expr instanceof Node\Expr\Ternary) {
|
||||
$ifSafe = $expr->if
|
||||
? $this->isSqlExpressionSanitized($expr->if, $taintTracker, $filePath, $depth + 1)
|
||||
: $this->isSqlExpressionSanitized($expr->cond, $taintTracker, $filePath, $depth + 1);
|
||||
$elseSafe = $this->isSqlExpressionSanitized($expr->else, $taintTracker, $filePath, $depth + 1);
|
||||
return $ifSafe && $elseSafe;
|
||||
}
|
||||
|
||||
// Null coalesce - both sides must be safe
|
||||
if ($expr instanceof Node\Expr\BinaryOp\Coalesce) {
|
||||
return $this->isSqlExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1)
|
||||
&& $this->isSqlExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
|
||||
}
|
||||
|
||||
// Parentheses
|
||||
if ($expr instanceof Node\Expr\Parenthesized) {
|
||||
return $this->isSqlExpressionSanitized($expr->expr, $taintTracker, $filePath, $depth + 1);
|
||||
}
|
||||
|
||||
// Arithmetic operations with safe operands
|
||||
if ($expr instanceof Node\Expr\BinaryOp\Plus
|
||||
|| $expr instanceof Node\Expr\BinaryOp\Minus
|
||||
|| $expr instanceof Node\Expr\BinaryOp\Mul
|
||||
|| $expr instanceof Node\Expr\BinaryOp\Div
|
||||
|| $expr instanceof Node\Expr\BinaryOp\Mod) {
|
||||
return $this->isSqlExpressionSanitized($expr->left, $taintTracker, $filePath, $depth + 1)
|
||||
&& $this->isSqlExpressionSanitized($expr->right, $taintTracker, $filePath, $depth + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a function call produces SQL-safe output
|
||||
*/
|
||||
private function isFunctionCallSqlSafe(Node\Expr\FuncCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
|
||||
{
|
||||
$funcName = $this->getCallName($expr);
|
||||
|
||||
if ($funcName === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known SQL sanitizer functions are safe
|
||||
if (in_array($funcName, self::SQL_SANITIZERS)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sanitizer-breaking functions are NEVER safe
|
||||
if (in_array($funcName, self::SQL_SANITIZER_BREAKERS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// filter_var with specific filters
|
||||
if ($funcName === 'filter_var') {
|
||||
$args = $this->getArguments($expr);
|
||||
if (count($args) >= 2 && $args[1]->value instanceof Node\Expr\ConstFetch) {
|
||||
$filter = $args[1]->value->name->toString();
|
||||
$safeFilters = ['FILTER_VALIDATE_INT', 'FILTER_VALIDATE_FLOAT', 'FILTER_SANITIZE_NUMBER_INT', 'FILTER_SANITIZE_NUMBER_FLOAT'];
|
||||
if (in_array($filter, $safeFilters)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze user-defined functions
|
||||
return $this->analyzeUserFunctionForSqlSafety($funcName, $expr->args, $taintTracker, $filePath, $depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method call produces SQL-safe output
|
||||
*/
|
||||
private function isMethodCallSqlSafe(Node\Expr\MethodCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
|
||||
{
|
||||
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
|
||||
|
||||
if ($methodName === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known sanitizer methods (PDO::quote, mysqli::real_escape_string, etc.)
|
||||
if (in_array($methodName, self::SQL_SANITIZER_METHODS)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sanitizer-breaking methods
|
||||
if (in_array($methodName, self::SQL_SANITIZER_BREAKERS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Chained calls - check if inner call is a sanitizer
|
||||
if ($expr->var instanceof Node\Expr\MethodCall) {
|
||||
$innerMethod = $expr->var->name instanceof Node\Identifier ? $expr->var->name->toString() : null;
|
||||
if ($innerMethod && in_array($innerMethod, self::SQL_SANITIZER_METHODS)) {
|
||||
// Check if this method doesn't break sanitization
|
||||
if (!in_array($methodName, self::SQL_SANITIZER_BREAKERS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a static method call produces SQL-safe output
|
||||
*/
|
||||
private function isStaticCallSqlSafe(Node\Expr\StaticCall $expr, TaintTracker $taintTracker, string $filePath, int $depth): bool
|
||||
{
|
||||
$className = $expr->class instanceof Node\Name ? $expr->class->toString() : null;
|
||||
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
|
||||
|
||||
if ($className === null || $methodName === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known sanitizer static methods
|
||||
if (in_array($methodName, self::SQL_SANITIZER_METHODS)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Type casting methods
|
||||
$safeClasses = ['Str', 'Illuminate\\Support\\Str'];
|
||||
if (in_array($className, $safeClasses)) {
|
||||
if (in_array($methodName, ['toInteger', 'toFloat'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a user-defined function to determine if it returns SQL-safe output
|
||||
*/
|
||||
private function analyzeUserFunctionForSqlSafety(
|
||||
string $funcName,
|
||||
array $args,
|
||||
TaintTracker $taintTracker,
|
||||
string $filePath,
|
||||
int $depth
|
||||
): bool {
|
||||
// Check cache
|
||||
$cacheKey = $funcName . ':sql_safe';
|
||||
if (isset($this->functionSanitizeCache[$cacheKey])) {
|
||||
return $this->functionSanitizeCache[$cacheKey];
|
||||
}
|
||||
|
||||
$callGraph = $this->getCallGraphFromTracker($taintTracker);
|
||||
|
||||
if ($callGraph === null) {
|
||||
$this->functionSanitizeCache[$cacheKey] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find function definition
|
||||
$definition = $callGraph['definitions'][$funcName] ?? null;
|
||||
|
||||
if ($definition === null) {
|
||||
foreach ($callGraph['classMethods'] ?? [] as $name => $def) {
|
||||
if (str_ends_with($name, "::{$funcName}")) {
|
||||
$definition = $def;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($definition === null || !isset($definition['node'])) {
|
||||
$this->functionSanitizeCache[$cacheKey] = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Analyze return statements
|
||||
$result = $this->analyzeReturnStatementsForSqlSafety($definition['node'], $depth + 1);
|
||||
$this->functionSanitizeCache[$cacheKey] = $result;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze return statements in a function for SQL safety
|
||||
*/
|
||||
private function analyzeReturnStatementsForSqlSafety(Node $functionNode, int $depth): bool
|
||||
{
|
||||
if ($depth > 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmts = $functionNode->stmts ?? [];
|
||||
$returnResults = [];
|
||||
$hasSanitizerBreaker = false;
|
||||
|
||||
foreach ($stmts as $stmt) {
|
||||
$result = $this->analyzeStatementForSqlSafety($stmt, $depth, $hasSanitizerBreaker);
|
||||
if ($result !== null) {
|
||||
$returnResults[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
// If any sanitizer breaker is found, the function is unsafe
|
||||
if ($hasSanitizerBreaker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no returns found, assume unsafe
|
||||
if (empty($returnResults)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All returns must be safe
|
||||
return !in_array(false, $returnResults, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a statement for SQL safety
|
||||
*/
|
||||
private function analyzeStatementForSqlSafety(Node $stmt, int $depth, bool &$hasSanitizerBreaker): ?bool
|
||||
{
|
||||
// Check for return statements
|
||||
if ($stmt instanceof Node\Stmt\Return_ && $stmt->expr !== null) {
|
||||
return $this->isReturnExpressionSqlSafe($stmt->expr, $depth);
|
||||
}
|
||||
|
||||
// Check for expression statements that might break sanitization
|
||||
if ($stmt instanceof Node\Stmt\Expression) {
|
||||
if ($this->containsSanitizerBreaker($stmt->expr)) {
|
||||
$hasSanitizerBreaker = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check nested statements
|
||||
$childStmts = [];
|
||||
if (isset($stmt->stmts) && is_array($stmt->stmts)) {
|
||||
$childStmts = array_merge($childStmts, $stmt->stmts);
|
||||
}
|
||||
if (isset($stmt->else) && isset($stmt->else->stmts)) {
|
||||
$childStmts = array_merge($childStmts, $stmt->else->stmts);
|
||||
}
|
||||
if (isset($stmt->elseifs)) {
|
||||
foreach ($stmt->elseifs as $elseif) {
|
||||
if (isset($elseif->stmts)) {
|
||||
$childStmts = array_merge($childStmts, $elseif->stmts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($childStmts as $childStmt) {
|
||||
$result = $this->analyzeStatementForSqlSafety($childStmt, $depth, $hasSanitizerBreaker);
|
||||
if ($result !== null) {
|
||||
$results[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($results)) {
|
||||
return !in_array(false, $results, true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a return expression is SQL-safe
|
||||
*/
|
||||
private function isReturnExpressionSqlSafe(Node $expr, int $depth): bool
|
||||
{
|
||||
// Direct SQL sanitizer function call
|
||||
if ($expr instanceof Node\Expr\FuncCall) {
|
||||
$funcName = $this->getCallName($expr);
|
||||
if ($funcName && in_array($funcName, self::SQL_SANITIZERS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Method call to sanitizer method
|
||||
if ($expr instanceof Node\Expr\MethodCall) {
|
||||
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
|
||||
if ($methodName && in_array($methodName, self::SQL_SANITIZER_METHODS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric literal
|
||||
if ($expr instanceof Node\Scalar\LNumber || $expr instanceof Node\Scalar\DNumber) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Type cast to int/float
|
||||
if ($expr instanceof Node\Expr\Cast\Int_ || $expr instanceof Node\Expr\Cast\Double) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// String literal (safe if no user input)
|
||||
if ($expr instanceof Node\Scalar\String_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Concatenation where all parts are safe
|
||||
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $this->isReturnExpressionSqlSafe($expr->left, $depth)
|
||||
&& $this->isReturnExpressionSqlSafe($expr->right, $depth);
|
||||
}
|
||||
|
||||
// Ternary where both branches are safe
|
||||
if ($expr instanceof Node\Expr\Ternary) {
|
||||
$ifExpr = $expr->if ?? $expr->cond;
|
||||
return $this->isReturnExpressionSqlSafe($ifExpr, $depth)
|
||||
&& $this->isReturnExpressionSqlSafe($expr->else, $depth);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an expression contains sanitizer-breaking function calls
|
||||
*/
|
||||
private function containsSanitizerBreaker(Node $expr): bool
|
||||
{
|
||||
if ($expr instanceof Node\Expr\FuncCall) {
|
||||
$funcName = $this->getCallName($expr);
|
||||
if ($funcName && in_array($funcName, self::SQL_SANITIZER_BREAKERS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($expr instanceof Node\Expr\MethodCall) {
|
||||
$methodName = $expr->name instanceof Node\Identifier ? $expr->name->toString() : null;
|
||||
if ($methodName && in_array($methodName, self::SQL_SANITIZER_BREAKERS)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively check child nodes
|
||||
foreach ($expr->getSubNodeNames() as $name) {
|
||||
$subNode = $expr->{$name};
|
||||
if ($subNode instanceof Node) {
|
||||
if ($this->containsSanitizerBreaker($subNode)) {
|
||||
return true;
|
||||
}
|
||||
} elseif (is_array($subNode)) {
|
||||
foreach ($subNode as $item) {
|
||||
if ($item instanceof Node && $this->containsSanitizerBreaker($item)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if concatenation has tainted unsanitized parts
|
||||
*/
|
||||
private function hasTaintedUnsanitizedPart(Node $node, TaintTracker $taintTracker, string $filePath): bool
|
||||
{
|
||||
if ($node instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $this->hasTaintedUnsanitizedPart($node->left, $taintTracker, $filePath)
|
||||
|| $this->hasTaintedUnsanitizedPart($node->right, $taintTracker, $filePath);
|
||||
}
|
||||
|
||||
// Check if this part is sanitized
|
||||
if ($this->isSqlExpressionSanitized($node, $taintTracker, $filePath, 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Scalar\Encapsed) {
|
||||
foreach ($node->parts as $part) {
|
||||
if (!$part instanceof Node\Scalar\EncapsedStringPart) {
|
||||
if ($taintTracker->isTainted($part, $filePath) &&
|
||||
!$this->isSqlExpressionSanitized($part, $taintTracker, $filePath, 0)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return $taintTracker->isTainted($node, $filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the call graph from the taint tracker using reflection
|
||||
*/
|
||||
private function getCallGraphFromTracker(TaintTracker $taintTracker): ?array
|
||||
{
|
||||
try {
|
||||
$reflection = new \ReflectionClass($taintTracker);
|
||||
$property = $reflection->getProperty('callGraph');
|
||||
$property->setAccessible(true);
|
||||
return $property->getValue($taintTracker);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function containsConcatenation(Node $node): bool
|
||||
{
|
||||
return $node instanceof Node\Expr\BinaryOp\Concat
|
||||
|| $node instanceof Node\Scalar\Encapsed
|
||||
|| $node instanceof Node\Expr\AssignOp\Concat;
|
||||
}
|
||||
|
||||
private function hasTaintedPart(Node $node, TaintTracker $taintTracker, string $filePath): bool
|
||||
{
|
||||
if ($node instanceof Node\Expr\BinaryOp\Concat) {
|
||||
return $taintTracker->isTainted($node->left, $filePath)
|
||||
|| $taintTracker->isTainted($node->right, $filePath)
|
||||
|| $this->hasTaintedPart($node->left, $taintTracker, $filePath)
|
||||
|| $this->hasTaintedPart($node->right, $taintTracker, $filePath);
|
||||
}
|
||||
|
||||
if ($node instanceof Node\Scalar\Encapsed) {
|
||||
foreach ($node->parts as $part) {
|
||||
if ($taintTracker->isTainted($part, $filePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $taintTracker->isTainted($node, $filePath);
|
||||
}
|
||||
}
|
||||
1251
src/Rules/XssRule.php
Normal file
1251
src/Rules/XssRule.php
Normal file
File diff suppressed because it is too large
Load Diff
308
src/SecurityLinter.php
Normal file
308
src/SecurityLinter.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace SecurityLinter;
|
||||
|
||||
use SecurityLinter\Analyzer\FileAnalyzer;
|
||||
use SecurityLinter\Analyzer\CallGraphBuilder;
|
||||
use SecurityLinter\Analyzer\TaintTracker;
|
||||
use SecurityLinter\Analyzer\TaintPreprocessor;
|
||||
use SecurityLinter\Report\Vulnerability;
|
||||
use SecurityLinter\Report\ReportGenerator;
|
||||
use SecurityLinter\Rules\RuleInterface;
|
||||
use SecurityLinter\Rules\XssRule;
|
||||
use SecurityLinter\Rules\SqlInjectionRule;
|
||||
use SecurityLinter\Rules\CommandInjectionRule;
|
||||
use SecurityLinter\Rules\PathTraversalRule;
|
||||
use SecurityLinter\Rules\AuthenticationRule;
|
||||
use SecurityLinter\Rules\CsrfSessionRule;
|
||||
use SecurityLinter\Rules\InsecureConfigRule;
|
||||
|
||||
/**
|
||||
* PHP/Laravel Security Linter
|
||||
*
|
||||
* Analyzes PHP and Laravel code for security vulnerabilities
|
||||
* with recursive call tracing capabilities.
|
||||
*/
|
||||
class SecurityLinter
|
||||
{
|
||||
/** @var RuleInterface[] */
|
||||
private array $rules = [];
|
||||
|
||||
/** @var Vulnerability[] */
|
||||
private array $vulnerabilities = [];
|
||||
|
||||
private FileAnalyzer $fileAnalyzer;
|
||||
private CallGraphBuilder $callGraphBuilder;
|
||||
private TaintTracker $taintTracker;
|
||||
private TaintPreprocessor $taintPreprocessor;
|
||||
private ReportGenerator $reportGenerator;
|
||||
|
||||
private array $config = [
|
||||
'recursive_depth' => 10,
|
||||
'follow_includes' => true,
|
||||
'laravel_mode' => true,
|
||||
'severity_threshold' => 'low',
|
||||
'exclude_patterns' => ['vendor/*', 'node_modules/*', 'storage/*'],
|
||||
'include_patterns' => [], // Patterns that override exclude
|
||||
];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge($this->config, $config);
|
||||
$this->fileAnalyzer = new FileAnalyzer();
|
||||
$this->callGraphBuilder = new CallGraphBuilder();
|
||||
$this->taintTracker = new TaintTracker($this->config['recursive_depth']);
|
||||
$this->taintPreprocessor = new TaintPreprocessor($this->taintTracker);
|
||||
$this->reportGenerator = new ReportGenerator();
|
||||
|
||||
$this->registerDefaultRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register default security rules based on guidelines
|
||||
*/
|
||||
private function registerDefaultRules(): void
|
||||
{
|
||||
$this->rules = [
|
||||
new XssRule(),
|
||||
new SqlInjectionRule(),
|
||||
new CommandInjectionRule(),
|
||||
new PathTraversalRule(),
|
||||
new AuthenticationRule(),
|
||||
new CsrfSessionRule(),
|
||||
new InsecureConfigRule(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom rule
|
||||
*/
|
||||
public function addRule(RuleInterface $rule): self
|
||||
{
|
||||
$this->rules[] = $rule;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a single file
|
||||
*/
|
||||
public function analyzeFile(string $filePath): array
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
throw new \InvalidArgumentException("File not found: {$filePath}");
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
$ast = $this->fileAnalyzer->parse($content, $filePath);
|
||||
|
||||
if ($ast === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build call graph for this file
|
||||
$this->callGraphBuilder->buildFromAst($ast, $filePath);
|
||||
|
||||
// Set call graph in taint tracker
|
||||
$this->taintTracker->setCallGraph($this->callGraphBuilder->getCallGraph());
|
||||
|
||||
// Preprocess to track taint propagation through assignments
|
||||
$this->taintPreprocessor->process($ast, $filePath);
|
||||
|
||||
// Analyze with each rule
|
||||
foreach ($this->rules as $rule) {
|
||||
$ruleVulns = $rule->analyze($ast, $filePath, $this->taintTracker);
|
||||
$this->vulnerabilities = array_merge($this->vulnerabilities, $ruleVulns);
|
||||
}
|
||||
|
||||
return $this->vulnerabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a directory recursively
|
||||
*/
|
||||
public function analyzeDirectory(string $directory): array
|
||||
{
|
||||
$this->vulnerabilities = [];
|
||||
$files = $this->findPhpFiles($directory);
|
||||
|
||||
// First pass: build complete call graph
|
||||
foreach ($files as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$ast = $this->fileAnalyzer->parse($content, $file);
|
||||
if ($ast !== null) {
|
||||
$this->callGraphBuilder->buildFromAst($ast, $file);
|
||||
}
|
||||
}
|
||||
|
||||
// Set call graph in taint tracker for recursive analysis
|
||||
$this->taintTracker->setCallGraph($this->callGraphBuilder->getCallGraph());
|
||||
|
||||
// Second pass: preprocess all files for taint tracking
|
||||
foreach ($files as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$ast = $this->fileAnalyzer->parse($content, $file);
|
||||
|
||||
if ($ast !== null) {
|
||||
$this->taintPreprocessor->process($ast, $file);
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: analyze each file with full call graph and taint context
|
||||
foreach ($files as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$ast = $this->fileAnalyzer->parse($content, $file);
|
||||
|
||||
if ($ast === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->rules as $rule) {
|
||||
$ruleVulns = $rule->analyze($ast, $file, $this->taintTracker);
|
||||
$this->vulnerabilities = array_merge($this->vulnerabilities, $ruleVulns);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate vulnerabilities
|
||||
$this->vulnerabilities = $this->deduplicateVulnerabilities($this->vulnerabilities);
|
||||
|
||||
return $this->vulnerabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all PHP files in directory
|
||||
*/
|
||||
private function findPhpFiles(string $directory): array
|
||||
{
|
||||
$files = [];
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $this->isPhpFile($file->getPathname())) {
|
||||
if (!$this->isExcluded($file->getPathname(), $directory)) {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is a PHP file
|
||||
*/
|
||||
private function isPhpFile(string $path): bool
|
||||
{
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
return in_array($ext, ['php', 'phtml', 'blade.php'], true)
|
||||
|| str_ends_with($path, '.blade.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is excluded
|
||||
*
|
||||
* Include patterns take precedence over exclude patterns.
|
||||
* If a path matches an include pattern, it will not be excluded.
|
||||
*/
|
||||
private function isExcluded(string $path, string $basePath): bool
|
||||
{
|
||||
$relativePath = str_replace($basePath . '/', '', $path);
|
||||
|
||||
// Check if explicitly included (include overrides exclude)
|
||||
foreach ($this->config['include_patterns'] as $pattern) {
|
||||
if (fnmatch($pattern, $relativePath)) {
|
||||
return false; // Explicitly included, don't exclude
|
||||
}
|
||||
}
|
||||
|
||||
// Check if excluded
|
||||
foreach ($this->config['exclude_patterns'] as $pattern) {
|
||||
if (fnmatch($pattern, $relativePath)) {
|
||||
return true;
|
||||
}
|
||||
// Also check parent directories
|
||||
$dir = dirname($relativePath);
|
||||
while ($dir !== '.' && $dir !== '') {
|
||||
if (fnmatch($pattern, $dir) || fnmatch($pattern, $dir . '/')) {
|
||||
return true;
|
||||
}
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate vulnerabilities
|
||||
*/
|
||||
private function deduplicateVulnerabilities(array $vulnerabilities): array
|
||||
{
|
||||
$seen = [];
|
||||
$unique = [];
|
||||
|
||||
foreach ($vulnerabilities as $vuln) {
|
||||
$key = $vuln->getUniqueKey();
|
||||
if (!isset($seen[$key])) {
|
||||
$seen[$key] = true;
|
||||
$unique[] = $vuln;
|
||||
}
|
||||
}
|
||||
|
||||
return $unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vulnerabilities filtered by severity
|
||||
*/
|
||||
public function getVulnerabilities(string $minSeverity = 'low'): array
|
||||
{
|
||||
$severityOrder = ['low' => 0, 'medium' => 1, 'high' => 2, 'critical' => 3];
|
||||
$minLevel = $severityOrder[$minSeverity] ?? 0;
|
||||
|
||||
return array_filter($this->vulnerabilities, function (Vulnerability $v) use ($severityOrder, $minLevel) {
|
||||
$level = $severityOrder[$v->getSeverity()] ?? 0;
|
||||
return $level >= $minLevel;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate report in specified format
|
||||
*/
|
||||
public function generateReport(string $format = 'text', ?array $vulnerabilities = null): string
|
||||
{
|
||||
$vulns = $vulnerabilities ?? $this->vulnerabilities;
|
||||
return $this->reportGenerator->generate($vulns, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get call graph for debugging
|
||||
*/
|
||||
public function getCallGraph(): array
|
||||
{
|
||||
return $this->callGraphBuilder->getCallGraph();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
public function getStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'total' => count($this->vulnerabilities),
|
||||
'by_severity' => ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0],
|
||||
'by_type' => [],
|
||||
];
|
||||
|
||||
foreach ($this->vulnerabilities as $vuln) {
|
||||
$stats['by_severity'][$vuln->getSeverity()]++;
|
||||
$type = $vuln->getType();
|
||||
$stats['by_type'][$type] = ($stats['by_type'][$type] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user