Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 루프를 안전하게 닫습니다.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "*"`)
Expand Down
100 changes: 100 additions & 0 deletions scanner/rules/php-wordpress.yml
Original file line number Diff line number Diff line change
@@ -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"
127 changes: 127 additions & 0 deletions tests/test_php_wordpress_rules.py
Original file line number Diff line number Diff line change
@@ -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(
"<?php\n"
"eval($_GET['code']);\n"
"$wpdb->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("<?php\ndefine( 'WP_DEBUG', true );\n")

findings = _scan_file(plugin, tmp_path) + _scan_file(config, tmp_path)
fired = {f["rule_id"] for f in findings}
assert set(PHP_RULE_IDS) <= fired

# the same code in a non-PHP file must not trigger the pack
script = tmp_path / "app.js"
script.write_text("eval($_GET['code']);\n")
js_fired = {
f["rule_id"]
for f in _scan_file(script, tmp_path)
if f["rule_id"] in PHP_RULE_IDS
}
assert js_fired == set()
Loading