|
1 | | -Verdict: Mostly False Positives / Scanner Noise |
| 1 | +# WPCC Pattern Library — False Positive Review |
| 2 | +**Source:** AI review of creditconnection2-self-service scan |
| 3 | +**Date:** 2026-03-23 |
| 4 | +**Scan findings:** 99 total | **Estimated true positives after fixes:** ~40 |
2 | 5 |
|
3 | | -"Shell command execution" (CRITICAL) — False Positive |
4 | | -All 8 findings flag curl_exec($curl). This is PHP's cURL library function, not shell execution. It's the standard way to make HTTP requests in PHP without WordPress's wp_remote_* wrappers. No shell is involved — completely safe. |
5 | | - |
6 | | -"Direct superglobal manipulation" on CURLOPT_POST/CURLOPT_POSTFIELDS (HIGH) — False Positive |
7 | | -The scanner is incorrectly matching curl_setopt($curl, CURLOPT_POST, true) as "superglobal manipulation." These are cURL options, not $_POST superglobal writes. This accounts for ~17 of the findings. |
8 | | - |
9 | | -"Dynamic PHP include/require" (CRITICAL) — False Positive |
10 | | -Both check-user-meta.php:13 and test-alternate-registry-id.php:24 are WP-CLI test scripts that locate wp-load.php from a hardcoded relative path array. The $path variable is never user-controlled — it's iterated from a static array. No risk. |
| 6 | +--- |
11 | 7 |
|
12 | | -"N+1 query pattern" (CRITICAL) — False Positive |
13 | | -check-user-meta.php:23 — This is a flat script calling get_user_meta() sequentially for a single user, not inside a loop over users. |
14 | | -class-cr-business-rest-api.php:245 — This is a single get_user_meta() re-read after processing, not an N+1 pattern. |
| 8 | +## Action Items |
15 | 9 |
|
16 | | -"Admin function missing capability check" (HIGH) — False Positive |
17 | | -credit-registry-forms.php:48 — add_action('admin_notices', ...) is a standard WordPress pattern for showing a dependency notice when a plugin is deactivated. The admin_notices hook itself only fires in the admin panel for authenticated users. The unset($_GET['activate']) on line 51 is also a standard WP pattern to suppress the "Plugin activated" message after forced deactivation. |
| 10 | +### ✅ Fix Now — High Confidence, Low Effort |
18 | 11 |
|
19 | | -6. Unsanitized $_GET['view_file'] (HIGH) — Valid Issue |
20 | | -At admin-test-page.php:191, $_GET['view_file'] is used without sanitize_file_name(). The strpos($view_file, '..') === false check on line 193 is a weak directory traversal guard (can be bypassed with encodings). Should use sanitize_file_name() like view_dir does on line 147. |
| 12 | +- [x] **FIX `php-shell-exec-functions.json` — `exec-call` pattern matches `curl_exec()`** ✅ *Fixed in commit 740ba08* |
| 13 | + **Pattern:** `exec[[:space:]]*\(` has no word boundary → matches `curl_exec(`. |
| 14 | + **Fix:** Change to `\bexec[[:space:]]*\(` in the `exec-call` sub-pattern. |
| 15 | + **File:** `dist/patterns/php-shell-exec-functions.json` |
| 16 | + **FPs eliminated:** 8 (all CRITICAL — all were `curl_exec($curl)` calls) |
21 | 17 |
|
22 | | -7. Unsanitized $_GET['view_dir'] display before sanitization (HIGH) — Valid Issue |
23 | | -At admin-test-page.php:145, $_GET['view_dir'] is output with esc_html() (safe for XSS), but the sanitization via sanitize_file_name() happens on line 147 — after the display. The display itself is safe due to esc_html(), but the order is confusing. |
| 18 | +- [x] **`php-dynamic-include.json` — WP-CLI bootstrap scripts no longer flagged as LFI** ✅ *Resolved in follow-up commit* |
| 19 | + **Finding:** `check-user-meta.php:13` and `test-alternate-registry-id.php:24` — `$path` is iterated from a hardcoded static array, never user-controlled. |
| 20 | + **Attempted fix (740ba08 — insufficient):** Added `wp-load` to `exclude_patterns`, but the actual matched line is `require_once $path;` — it does not contain `wp-load`. |
| 21 | + **Proper fix:** Added new `exclude_if_file_contains` capability to the simple pattern runner and `dist/bin/check-performance.sh`. When a matched file's content contains any string listed in the new `exclude_if_file_contains` JSON array, all matches in that file are suppressed. Added `"wp eval-file"` to `php-dynamic-include.json` under this key — both WP-CLI scripts have this string in their docblock comment. |
| 22 | + **Files changed:** `dist/bin/check-performance.sh` (runner feature), `dist/patterns/php-dynamic-include.json` (new exclusion key) |
| 23 | + **FPs eliminated:** 2 (both CRITICAL) |
24 | 24 |
|
25 | | -8. $_POST['force_refresh'] (HIGH) — Low Risk |
26 | | -At api-functions.php:1014, this is compared strictly to the string 'true', so it can only ever be a boolean. No injection vector. However, this runs inside a WP AJAX handler — verify nonce checks exist upstream. |
| 25 | +--- |
27 | 26 |
|
28 | | -Many $_GET reads with sanitize_text_field() — False Positive |
29 | | -Lines in class-cr-rest-api.php and class-cr-business-rest-api.php that do sanitize_text_field($_GET['registry_id']) are already properly sanitized. The scanner flags the raw $_GET access but ignores the wrapping sanitization. |
| 27 | +### ✅ Implemented After Investigation |
| 28 | + |
| 29 | +- [x] **FIX `spo-002-superglobals` inline grep corruption** ✅ *Implemented in scanner* |
| 30 | + **Scan log findings:** 31 total spo-002 findings. 16 are `CURLOPT_POST`/`CURLOPT_POSTFIELDS`, 2 are JS `type: 'POST'` strings, 4 are `$_SERVER` reads (SERVER not in the pattern alternation), 1 is the only legitimate finding (line 1014). |
| 31 | + **Root cause confirmed:** The inline bash spo-002 grep (check-performance.sh ~line 3723) uses a **double-quoted string with `\\$_`**. In bash double-quotes, `\\` → `\` and then `$_` starts expansion of the bash `$_` special variable (last argument of the previous command). At runtime, `$_` contains the last argument from `text_echo "▸ Direct superglobal manipulation..."` — an ANSI-coloured string including `[HIGH]`. This corrupts the entire ERE pattern, causing it to match incorrectly in a non-deterministic way. |
| 32 | + **The JSON pattern itself (`\$_(GET|POST...)`) is correct** — tested via `load_pattern` + direct grep, it does NOT match CURLOPT_POST. The bug is entirely in the inline bash code, not the JSON pattern file. |
| 33 | + **Fix implemented:** Changed the inline grep at line 3723 from double-quoted to single-quoted string, which prevents `$_` expansion. This is a **scanner bug, not a pattern bug**. The JSON file did not need to change. |
| 34 | + **File to fix:** `dist/bin/check-performance.sh` ~line 3723 |
| 35 | + **Verified impact:** `spo-002-superglobals` dropped from **31 → 3** findings in the follow-up scan. Remaining 3 are legitimate review cases: `$_POST['force_refresh']`, `unset($_GET['activate'])`, and `$_GET['view_errors']` conditional logic. |
| 36 | + |
| 37 | +- [x] **FIX simple runner ignoring `exclude_patterns` / `exclude_files`** ✅ *Implemented in scanner* |
| 38 | + **Scan log findings:** 30 `unsanitized-superglobal-read` findings. Confirmed FPs include: `class-cr-rest-api.php:90`, `class-cr-rest-api.php:98`, `class-cr-rest-api.php:843`, `class-cr-business-rest-api.php:103`, `class-cr-business-rest-api.php:138`, `class-cr-business-rest-api.php:857` — all are same-line ternary patterns like `isset($_GET['x']) ? sanitize_text_field($_GET['x']) : ''`. |
| 39 | + **Root cause confirmed:** The simple pattern runner (`check-performance.sh` ~line 5970) runs `cached_grep -E "$pattern_search"` but **never applies `exclude_patterns` from the JSON definition**. The `exclude_patterns` array in `unsanitized-superglobal-read.json` (which includes `sanitize_`, `isset\(`, `esc_`, etc.) is loaded but silently ignored. The legacy inline checks manually pipe through `grep -v` to apply exclusions; the JSON-driven simple runner does not. |
| 40 | + **This is NOT a multiline issue** — the flagged lines all have the sanitizer wrapper on the same line. The exclusion simply isn't being applied at all by the simple runner. |
| 41 | + **Additional FPs from same root cause:** `clear-person-cache.php:34`, `setup-user-registry-id.php:23-24`, `set-account-type.php:26-27` — all properly guarded `$_POST` reads with nonce verification on the same line. |
| 42 | + **Fix implemented:** The simple pattern runner now parses both `exclude_patterns` and `exclude_files` from the JSON pattern file and filters matches before JSON findings are added. This improves behavior across all JSON-defined `grep`/`simple` patterns, not just `unsanitized-superglobal-read`. |
| 43 | + **File to fix:** `dist/bin/check-performance.sh` ~line 5970 (simple pattern runner grep call) |
| 44 | + **Verified impact:** `unsanitized-superglobal-read` dropped from **30 → 19** findings in the follow-up scan. The remaining 19 are mostly other classes of reads that still require separate tuning, especially the dedicated `unsanitized-superglobal-isset-bypass` rule. |
30 | 45 |
|
31 | 46 | --- |
32 | 47 |
|
33 | | -Easy wins |
34 | | -curl_exec() flagged as shell execution — The regex is likely matching /\b(exec|shell_exec|system|passthru)\s*\(/. Just add a negative lookbehind for curl_: |
| 48 | +### 📋 Deferred — Investigate Further Before Implementing |
35 | 49 |
|
| 50 | +- [ ] **DEFERRED: Add admin-only hook whitelist for capability check false positives** |
| 51 | + **Finding:** `credit-registry-forms.php:48` — `add_action('admin_notices', ...)` flagged for missing capability check. `admin_notices` only fires for authenticated admin users. |
| 52 | + **Reviewer recommendation:** Whitelist inherently-admin-only hooks (`admin_notices`, `admin_init`, `admin_menu`, etc.) |
| 53 | + **Our assessment:** Correct diagnosis. Not fixable with regex alone — requires a hook whitelist in the scanner logic. Downgrade severity to INFO as interim. |
| 54 | + **Effort:** Low–Medium | **FPs eliminated:** 1 per occurrence |
36 | 55 |
|
37 | | -/\b(?<!curl_)(exec|shell_exec|system|passthru)\s*\(/ |
38 | | -CURLOPT_POST flagged as superglobal manipulation — The regex is probably matching POST in any context. If the rule targets $_POST, ensure the pattern anchors on the $_ prefix: |
| 56 | +- [ ] **DEFERRED: Strengthen N+1 loop detection to verify lexical containment** |
| 57 | + **Finding 1:** `check-user-meta.php:23` — `get_user_meta()` called sequentially for a single user, not inside a user loop. |
| 58 | + **Finding 2:** `class-cr-business-rest-api.php:245` — single `get_user_meta()` re-read after processing. |
| 59 | + **Reviewer recommendation:** Confirm the meta call is lexically inside a loop body (`{...}`), not just nearby by line count. |
| 60 | + **Our assessment:** The scanner has `is_iterating_over_multiple_objects()` heuristics. These may be gaps in that logic. Review and tighten the "loop containment" check. |
| 61 | + **Effort:** Medium | **FPs eliminated:** 2 |
39 | 62 |
|
| 63 | +--- |
| 64 | + |
| 65 | +### ✔️ No Action Required — Already Handled or Misdiagnosed |
40 | 66 |
|
41 | | -/\$_(POST|GET|REQUEST|SERVER)\s*\[/ |
42 | | -That alone would eliminate ~25 false positives from this report. |
| 67 | +- [x] **SKIP — `isset()` exclusion for superglobal reads** |
| 68 | + `isset\(` is already in `exclude_patterns` for `unsanitized-superglobal-read.json`. Reviewer's suggestion is already implemented. |
43 | 69 |
|
44 | | -Sanitized reads flagged as unsanitized — This is the biggest noise source. A single-line lookahead can catch the most common WordPress pattern where the $_GET/$_POST access is wrapped in a sanitization call: |
| 70 | +- [x] **SKIP — `$_` prefix anchoring for superglobal manipulation** |
| 71 | + All three sub-patterns in `spo-002-superglobals` already require `$_` prefix. The reviewer's suggested fix is already in place. |
45 | 72 |
|
| 73 | +- [x] **SKIP — Sanitizer negative-lookbehind regex for `unsanitized-superglobal-read`** |
| 74 | + The `exclude_patterns` list already handles same-line sanitizer wrapping. The multiline case is a structural grep limitation, not addressable by the proposed regex. |
46 | 75 |
|
47 | | -/(?<!sanitize_text_field\()(?<!sanitize_file_name\()(?<!esc_html\()(?<!esc_attr\()(?<!wp_verify_nonce\()(?<!absint\()\$_(GET|POST|REQUEST)\s*\[/ |
48 | | -Or invert it — match the line, then exclude if the $_GET[...] appears as an argument to a known sanitizer on the same line. A two-pass approach is cleaner: |
| 76 | +--- |
49 | 77 |
|
50 | | -Match $_GET['foo'] |
51 | | -Skip if the match is inside sanitize_text_field(, absint(, wp_verify_nonce(, etc. |
52 | | -isset($_GET[...]) / isset($_POST[...]) flagged — isset() checks don't read the value. These should be excluded entirely: |
| 78 | +## Valid Issues Found (Not FPs — Tracker for Plugin Owner) |
53 | 79 |
|
| 80 | +| # | File | Line | Issue | Risk | |
| 81 | +|---|------|------|-------|------| |
| 82 | +| 6 | `admin-test-page.php` | 191 | `$_GET['view_file']` used without `sanitize_file_name()`; `strpos($view_file, '..')` bypass-able via encoding | HIGH | |
| 83 | +| 7 | `admin-test-page.php` | 145 | `$_GET['view_dir']` displayed with `esc_html()` before `sanitize_file_name()` on line 147 — safe but misordered | LOW (confusing) | |
| 84 | +| 8 | `api-functions.php` | 1014 | `$_POST['force_refresh']` in AJAX handler — strict `=== 'true'` comparison limits injection, but verify nonce upstream | LOW–MEDIUM | |
54 | 85 |
|
55 | | -/(?<!isset\()\$_(GET|POST|REQUEST)\[/ |
56 | | -Harder but doable |
57 | | -Dynamic include with hardcoded paths — The scanner can't easily tell that $path comes from a static array vs user input. But you could reduce noise by checking if the variable was assigned from a literal array within N lines above. Alternatively, skip flagging when the require is inside a file_exists() guard on the same variable — that pattern almost always indicates a bootstrap loader, not user-controlled inclusion. |
| 86 | +--- |
58 | 87 |
|
59 | | -N+1 detection — The heuristic "meta call inside loop" needs to actually verify there is a loop. If the scanner is matching get_user_meta anywhere near a foreach/for/while, it should confirm the meta call is lexically inside the loop body (between { and }), not just nearby by line count. |
| 88 | +## Impact Summary |
60 | 89 |
|
61 | | -Not really fixable with regex |
62 | | -Admin capability check — Determining whether a current_user_can() check exists somewhere in the call chain before an add_action('admin_notices', ...) requires control-flow analysis. A regex scanner can't do this reliably. Best approach: downgrade this to INFO severity, or maintain a whitelist of hooks that are inherently admin-only (admin_notices, admin_init, etc.). |
| 90 | +| Fix | File to Edit | Effort | FPs Eliminated | Status | |
| 91 | +|-----|-------------|--------|---------------|--------| |
| 92 | +| `\b` word boundary on `exec-call` | `php-shell-exec-functions.json` | 1 line | 8 | ✅ Done (740ba08) | |
| 93 | +| `exclude_if_file_contains` + `wp eval-file` | `check-performance.sh` + `php-dynamic-include.json` | Medium | 2 verified | ✅ Done | |
| 94 | +| Single-quote inline spo-002 grep | `check-performance.sh` ~L3723 | 1 line | 28 verified | ✅ Done | |
| 95 | +| Apply `exclude_patterns` in simple runner | `check-performance.sh` ~L5970 | Medium | 11 verified | ✅ Done | |
| 96 | +| Admin-only hook whitelist | `check-performance.sh` | Medium | 1+ per scan | 📋 Deferred | |
| 97 | +| N+1 loop containment tightening | `check-performance.sh` | Medium | 2+ per scan | 📋 Deferred | |
63 | 98 |
|
64 | | -Summary of impact |
65 | | -Fix Effort False positives eliminated |
66 | | -curl_exec negative lookbehind 1 line 8 |
67 | | -Anchor superglobal on $_ prefix 1 line ~17 |
68 | | -Skip isset() wrapping 1 line ~10 |
69 | | -Skip known sanitizer wrapping Medium ~20 |
70 | | -Loop verification for N+1 Medium 2 |
71 | | -Whitelist admin-only hooks Low 1 |
72 | | -The first three fixes alone would cut this report from 99 findings to roughly 40, with almost no loss of true positives. |
| 99 | +**Latest measured totals:** 99 findings before scanner fixes → **88 findings after first round** → **86 findings after dynamic-include fix**. |
0 commit comments