diff --git a/CHANGELOG.md b/CHANGELOG.md index 35124a4..84cb55a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### 추가 +- PHP / WordPress 룰팩 `scanner/rules/php-wordpress.yml` 추가 — 바이브 코딩·에이전시 산출물에 많은 PHP/WordPress 코드를 처음으로 커버합니다. 기존 내장 eval/SQL 룰은 `.js`/`.py` 확장자에만 적용되어 `.php` 파일은 사각지대였습니다. 6종 모두 `**/*.php` 경로로 스코프되며, 안전 코드(prepared statement, `$wpdb->prepare()`, 상수 include 등) 오탐 0을 테스트로 검증했습니다. + - `php-sql-concat` — `mysqli_query()`/`$wpdb->query()` 등에 `$_GET`/`$_POST`/`$_REQUEST`/`$_COOKIE`를 직접 연결·보간(SQL 주입, CWE-89). CRITICAL. + - `php-unserialize-user-input` — 요청 입력을 `unserialize()`(PHP object injection, CWE-502). CRITICAL. + - `php-include-user-input` — 요청 입력 기반 `include`/`require`(LFI/RFI, CWE-98). CRITICAL. + - `php-exec-user-input` — 요청 입력이 들어간 `exec`/`system`/`shell_exec`/`passthru`(OS 명령 주입, CWE-78). CRITICAL. + - `php-eval-usage` — PHP `eval()` 사용(CWE-95). HIGH. + - `wordpress-debug-enabled` — `WP_DEBUG` true(프로덕션 정보 노출, CWE-489). WARNING. + ### 추가 - `appguardrail fix` 명령 — 안전하고 결정적인 자동 수정을 적용합니다(기본 dry-run diff, `--apply`로 기록). 의미를 바꾸지 않는 순수 additive 변환만 수행하며, 첫 변환으로 외부 `target="_blank"` 링크에 `rel="noopener noreferrer"`를 추가합니다(reverse tabnabbing 방지). 동작을 바꾸는 수정(시크릿→env 등)은 위험하므로 자동 적용하지 않고 fix-pack 프롬프트로 남깁니다. scan→fix→verify 루프를 안전하게 닫습니다. diff --git a/README.md b/README.md index 920c539..e825110 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ Detects: - Trivy-backed dependency vulnerabilities, secrets, and misconfigurations - Bandit/Ruff/Semgrep/ZAP findings when their optional external engines are available - Dangerous Supabase/Firebase usage patterns +- PHP/WordPress risks (SQL concatenation with superglobals, `unserialize()`/`include`/shell-exec on request input, `eval()`, `WP_DEBUG` enabled) - API routes missing authentication - Public Firebase rules (`read/write: true`) - Dangerous CORS settings (`origin: "*"`) diff --git a/scanner/rules/php-wordpress.yml b/scanner/rules/php-wordpress.yml new file mode 100644 index 0000000..bf1f512 --- /dev/null +++ b/scanner/rules/php-wordpress.yml @@ -0,0 +1,100 @@ +rules: + # PHP / WordPress rule pack. The built-in dangerous-eval and + # sql-injection-risk rules only cover .js/.ts/.py extensions, so plain PHP + # and WordPress (plugin/theme/wp-config) code had no coverage before this + # pack. Every rule here is path-scoped to *.php files. + - id: php-eval-usage + patterns: + - pattern-regex: '\beval\s*\(' + message: | + Use of eval() detected in PHP code. eval() executes arbitrary PHP and is + a common remote-code-execution vector, especially when any part of the + argument is user-controlled. Replace it with explicit logic (call_user_func + against an allowlist, json_decode for data, etc.). + severity: HIGH + languages: [generic] + cwe: [CWE-95] + owasp: [A03:2021] + paths: + include: + - "**/*.php" + + - id: php-sql-concat + patterns: + - pattern-regex: '(?i)(?:\bmysqli_query|->\s*(?:query|get_results|get_row|get_var|get_col))\s*\((?:(?!prepare)[^;]){0,300}?\$_(?:GET|POST|REQUEST|COOKIE)\b' + message: | + SQL query built directly from request input ($_GET/$_POST/$_REQUEST/$_COOKIE) + detected. Concatenating or interpolating superglobals into mysqli_query() + or $wpdb->query()/get_results() is SQL injection. Use parameterized + queries: mysqli/PDO prepared statements, or $wpdb->prepare() in WordPress. + severity: CRITICAL + languages: [generic] + cwe: [CWE-89] + owasp: [A03:2021] + paths: + include: + - "**/*.php" + + - id: php-unserialize-user-input + patterns: + - pattern-regex: 'unserialize\s*\(\s*(?:base64_decode\s*\(\s*)?\$_(?:GET|POST|REQUEST|COOKIE)\b' + message: | + unserialize() called on request input. PHP object injection lets an + attacker instantiate arbitrary classes and chain magic methods + (__wakeup/__destruct) into remote code execution. Use json_decode() for + data interchange, or pass ['allowed_classes' => false] at minimum. + severity: CRITICAL + languages: [generic] + cwe: [CWE-502] + owasp: [A08:2021] + paths: + include: + - "**/*.php" + + - id: php-include-user-input + patterns: + - pattern-regex: '\b(?:include|require)(?:_once)?\s*\(?\s*(?:[\x27"][^\x27"\n]{0,80}[\x27"]\s*\.\s*)?\$_(?:GET|POST|REQUEST|COOKIE)\b' + message: | + include/require driven by request input detected. This is Local/Remote + File Inclusion: an attacker can load arbitrary files (../../wp-config.php) + or remote code. Map the request parameter to an allowlist of known page + names instead of passing it to include. + severity: CRITICAL + languages: [generic] + cwe: [CWE-98] + owasp: [A03:2021] + paths: + include: + - "**/*.php" + + - id: php-exec-user-input + patterns: + - pattern-regex: '\b(?:exec|system|shell_exec|passthru|popen|proc_open)\s*\(\s*[^;)]{0,160}?\$_(?:GET|POST|REQUEST|COOKIE)\b' + message: | + Shell execution built from request input detected. Passing + $_GET/$_POST/$_REQUEST/$_COOKIE into exec()/system()/shell_exec()/passthru() + is OS command injection. Avoid shelling out; if unavoidable, validate + against a strict allowlist and wrap each argument in escapeshellarg(). + severity: CRITICAL + languages: [generic] + cwe: [CWE-78] + owasp: [A03:2021] + paths: + include: + - "**/*.php" + + - id: wordpress-debug-enabled + patterns: + - pattern-regex: 'define\s*\(\s*[\x27"]WP_DEBUG[\x27"]\s*,\s*true' + message: | + WP_DEBUG is enabled. In production this prints PHP errors (paths, queries, + stack traces) to visitors and aids attackers. Set WP_DEBUG to false in + production, or pair it with WP_DEBUG_DISPLAY false and WP_DEBUG_LOG true + so errors go to a log instead of the page. + severity: WARNING + languages: [generic] + cwe: [CWE-489] + owasp: [A05:2021] + paths: + include: + - "**/*.php" diff --git a/tests/test_php_wordpress_rules.py b/tests/test_php_wordpress_rules.py new file mode 100644 index 0000000..32ce2c0 --- /dev/null +++ b/tests/test_php_wordpress_rules.py @@ -0,0 +1,127 @@ +"""Coverage tests for the PHP / WordPress rule pack (scanner/rules/php-wordpress.yml).""" + +from scanner.cli.appguardrail import SCAN_RULES, _scan_file + +PHP_RULE_IDS = [ + "php-eval-usage", + "php-sql-concat", + "php-unserialize-user-input", + "php-include-user-input", + "php-exec-user-input", + "wordpress-debug-enabled", +] + +_BY_ID = {} +for _r in SCAN_RULES: + _BY_ID.setdefault(_r["id"], _r) + + +def _rule(rule_id): + assert rule_id in _BY_ID, f"rule not loaded: {rule_id}" + return _BY_ID[rule_id] + + +def test_php_rules_loaded_and_scoped_to_php_files(): + for rule_id in PHP_RULE_IDS: + rule = _rule(rule_id) + assert rule["include_paths"] == ["**/*.php"], rule_id + # generic language: no extension filter, path glob does the scoping + assert rule["extensions"] is None, rule_id + + +def test_php_eval_usage(): + r = _rule("php-eval-usage") + assert r["severity"] == "HIGH" + assert r["pattern"].search("eval($_GET['code']);") + assert r["pattern"].search("eval ( base64_decode($payload) );") + # similarly named functions must not match + assert not r["pattern"].search("$score = evaluate($input);") + assert not r["pattern"].search("$mode = 'eval';") + + +def test_php_sql_concat(): + r = _rule("php-sql-concat") + assert r["severity"] == "CRITICAL" + assert r["pattern"].search( + 'mysqli_query($conn, "SELECT * FROM users WHERE id = " . $_GET[\'id\']);' + ) + assert r["pattern"].search('$wpdb->query("DELETE FROM wp_posts WHERE ID = $_POST[id]");') + assert r["pattern"].search('$wpdb->get_results("SELECT * FROM t WHERE a = $_REQUEST[a]");') + # parameterized queries must not match + assert not r["pattern"].search( + 'mysqli_query($conn, "SELECT * FROM users WHERE id = ?");' + ) + assert not r["pattern"].search( + '$wpdb->query($wpdb->prepare("SELECT * FROM t WHERE id = %d", $_GET[\'id\']));' + ) + assert not r["pattern"].search('$wpdb->query($sql);') + + +def test_php_unserialize_user_input(): + r = _rule("php-unserialize-user-input") + assert r["severity"] == "CRITICAL" + assert r["pattern"].search("$obj = unserialize($_COOKIE['session']);") + assert r["pattern"].search("unserialize(base64_decode($_POST['data']))") + # safe decoding of request input must not match + assert not r["pattern"].search("$data = json_decode($_POST['payload'], true);") + assert not r["pattern"].search("$obj = unserialize($trusted_cache_blob);") + + +def test_php_include_user_input(): + r = _rule("php-include-user-input") + assert r["severity"] == "CRITICAL" + assert r["pattern"].search("include($_GET['page']);") + assert r["pattern"].search("require_once 'pages/' . $_REQUEST['p'];") + assert r["pattern"].search("include_once $_POST['template'];") + # constant/allowlisted includes must not match + assert not r["pattern"].search("include __DIR__ . '/header.php';") + assert not r["pattern"].search("require_once ABSPATH . 'wp-settings.php';") + + +def test_php_exec_user_input(): + r = _rule("php-exec-user-input") + assert r["severity"] == "CRITICAL" + assert r["pattern"].search('system("ping " . $_GET[\'host\']);') + assert r["pattern"].search("exec($_POST['cmd'], $output);") + assert r["pattern"].search('shell_exec("convert $_REQUEST[file] out.png");') + # fixed commands and escaped args without superglobals must not match + assert not r["pattern"].search("system('ls -la /var/www');") + assert not r["pattern"].search("exec('git pull', $output);") + + +def test_wordpress_debug_enabled(): + r = _rule("wordpress-debug-enabled") + assert r["severity"] == "WARNING" + assert r["pattern"].search("define( 'WP_DEBUG', true );") + assert r["pattern"].search('define("WP_DEBUG",true);') + assert not r["pattern"].search("define( 'WP_DEBUG', false );") + assert not r["pattern"].search("define( 'WP_DEBUG_LOG', true );") + + +def test_php_rules_fire_end_to_end_on_php_paths_only(tmp_path): + plugin = tmp_path / "wp-content" / "plugins" / "demo" / "page.php" + plugin.parent.mkdir(parents=True) + plugin.write_text( + "query(\"DELETE FROM wp_posts WHERE ID = $_POST[id]\");\n" + "$obj = unserialize($_COOKIE['session']);\n" + "include($_GET['page']);\n" + "system(\"ping \" . $_GET['host']);\n" + ) + config = tmp_path / "wp-config.php" + config.write_text("