Skip to content

Commit 11d4efb

Browse files
committed
Fix AJAX nonce coverage detection and update scanner artifacts
1 parent 6db4d19 commit 11d4efb

4 files changed

Lines changed: 41 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ All notable changes to this project will be documented in this file.
3535

3636
- Fixed bash `local: can only be used in a function` errors that appeared on every scan invocation. The simple-pattern runner loop uses `local` in top-level scope; replaced with plain variable assignments
3737

38+
- Calibrated `wp_ajax handlers without nonce validation` detection in `dist/bin/check-performance.sh` to catch missing CSRF protection reliably:
39+
- Replaced pipe-based `safe_file_iterator ... | while` loop with process substitution `while ...; done < <(...)` so failure flags and counters are preserved (no subshell scope loss)
40+
- Improved handler-to-nonce coverage logic by comparing unique `wp_ajax_*` registrations to nonce checks instead of only checking whether any nonce exists in the file
41+
- Fixed grep-count fallback handling (`grep -c ... || true`) to avoid malformed `0\n0` values during arithmetic comparisons
42+
- Verified against the Bloomz universal child theme case where 27 AJAX endpoints were missing nonce verification
43+
3844
- N+1 pattern findings now include the actual source code line in the report. Previously the `code` field was empty because `find_meta_in_loop_line` only returned the line number without extracting the source text
3945

4046
### Tests

ask_self/ask_self_helpers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ def format_context(hits: list[dict[str, Any]], max_context_chars: int = 80000) -
183183
else:
184184
header = f"[{hit.get('path') or source or 'unknown'}]"
185185

186+
repo_label = str(hit.get("repo_label") or "").strip()
187+
if repo_label:
188+
header = f"[{repo_label} :: {header[1:-1]}]"
189+
186190
block = f"=== {header} ===\n{content}\n"
187191
if total + len(block) > max_context_chars:
188192
break

dist/PATTERN-LIBRARY.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"version": "1.0.0",
3-
"generated": "2026-03-24T02:51:36Z",
3+
"generated": "2026-04-16T19:47:39Z",
44
"summary": {
55
"total_patterns": 56,
66
"enabled": 56,
@@ -400,7 +400,7 @@
400400
"mitigation_detection": false,
401401
"heuristic": true,
402402
"file": "limit-multiplier-from-count.json",
403-
"search_pattern": "count\\(",
403+
"search_pattern": "count\\([^)]*\\)[[:space:]]*\\*[[:space:]]*[0-9]",
404404
"file_patterns": ["*.php"]
405405
},
406406
{

dist/bin/check-performance.sh

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4437,34 +4437,45 @@ AJAX_FILES=$(run_with_timeout "$MAX_SCAN_TIME" grep -rln $EXCLUDE_ARGS --include
44374437
if [ -n "$AJAX_FILES" ]; then
44384438
# SAFEGUARD: Use safe_file_iterator() instead of "for file in $AJAX_FILES"
44394439
# File paths with spaces will break the loop without this helper (see common-helpers.sh)
4440-
safe_file_iterator "$AJAX_FILES" | while IFS= read -r file; do
4441-
hook_count=$(grep -E "wp_ajax" "$file" 2>/dev/null | wc -l | tr -d '[:space:]')
4442-
nonce_count=$(grep -E "check_ajax_referer[[:space:]]*\\(|wp_verify_nonce[[:space:]]*\\(" "$file" 2>/dev/null | wc -l | tr -d '[:space:]')
4440+
# NOTE: Process substitution < <(...) is used instead of pipe | while to avoid subshell
4441+
# scoping — pipe creates a subshell where AJAX_NONCE_FAIL and AJAX_NONCE_FINDING_COUNT
4442+
# changes are lost when the subshell exits, causing the check to always report "passed".
4443+
while IFS= read -r file; do
4444+
# Count add_action('wp_ajax_*') registrations (both authenticated and nopriv variants)
4445+
# NOTE: grep -c always outputs "0" on no match (exit 1); use || true not || echo 0
4446+
# to avoid capturing "0\n0" which breaks integer comparisons downstream.
4447+
handler_count=$(grep -cE "add_action[[:space:]]*\([[:space:]]*['\"]wp_ajax_" "$file" 2>/dev/null || true)
4448+
nonce_count=$(grep -cE "check_ajax_referer[[:space:]]*\(|wp_verify_nonce[[:space:]]*\(" "$file" 2>/dev/null || true)
44434449

4444-
if [ -z "$hook_count" ] || [ "$hook_count" -eq 0 ]; then
4450+
if [ -z "$handler_count" ] || [ "$handler_count" -eq 0 ]; then
4451+
continue
4452+
fi
4453+
4454+
# Flag if zero nonce calls, OR if nonce calls are significantly fewer than handler
4455+
# registrations (each unique action appears twice: wp_ajax_ + wp_ajax_nopriv_,
4456+
# so nonce_count should be at least half the handler registrations).
4457+
# A file with 14 add_action lines and 0 nonce calls is clearly unprotected.
4458+
# A file with 14 add_action lines and 1-2 nonce calls is likely under-protected.
4459+
unique_handlers=$(grep -E "add_action[[:space:]]*\([[:space:]]*['\"]wp_ajax_" "$file" 2>/dev/null \
4460+
| grep -oE "'wp_ajax_[^']+'" | sed "s/wp_ajax_nopriv_/wp_ajax_/" | sort -u | wc -l | tr -d '[:space:]')
4461+
sufficient_nonces=$(( ${unique_handlers:-0} > 0 ? ${unique_handlers:-0} : 1 ))
4462+
4463+
if [ "${nonce_count:-0}" -ge "$sufficient_nonces" ]; then
44454464
continue
44464465
fi
44474466

4448-
# Require at least one nonce validation somewhere in the file
4449-
# if any wp_ajax hook is present. This avoids false positives in
4450-
# common patterns like shared handlers for wp_ajax_/wp_ajax_nopriv_
4451-
# while still flagging completely unprotected files.
4452-
if [ -z "$nonce_count" ] || [ "$nonce_count" -eq 0 ]; then
4453-
:
4454-
else
4455-
continue
4456-
fi
44574467
if should_suppress_finding "wp-ajax-no-nonce" "$file"; then
44584468
continue
44594469
fi
44604470

4461-
lineno=$(grep -n "wp_ajax" "$file" 2>/dev/null | head -1 | cut -d: -f1)
4462-
code=$(grep -n "wp_ajax" "$file" 2>/dev/null | head -1 | cut -d: -f2-)
4463-
text_echo " $file: wp_ajax handler missing nonce validation"
4464-
add_json_finding "ajax-no-nonce" "error" "$AJAX_NONCE_SEVERITY" "$file" "${lineno:-0}" "wp_ajax handler missing nonce validation" "$code"
4471+
lineno=$(grep -n "add_action.*wp_ajax_" "$file" 2>/dev/null | head -1 | cut -d: -f1)
4472+
code=$(grep -n "add_action.*wp_ajax_" "$file" 2>/dev/null | head -1 | cut -d: -f2-)
4473+
missing=$(( ${unique_handlers:-0} - ${nonce_count:-0} < 0 ? 0 : ${unique_handlers:-0} - ${nonce_count:-0} ))
4474+
text_echo " $file: $unique_handlers wp_ajax handler(s), only $nonce_count nonce check(s) — $missing handler(s) likely missing CSRF protection"
4475+
add_json_finding "ajax-no-nonce" "error" "$AJAX_NONCE_SEVERITY" "$file" "${lineno:-0}" "wp_ajax handlers missing CSRF nonce verification ($unique_handlers handler(s), $nonce_count nonce check(s))" "$code"
44654476
AJAX_NONCE_FAIL=true
44664477
((AJAX_NONCE_FINDING_COUNT++))
4467-
done
4478+
done < <(safe_file_iterator "$AJAX_FILES")
44684479
fi
44694480
if [ "$AJAX_NONCE_FAIL" = true ]; then
44704481
if [ "$AJAX_NONCE_SEVERITY" = "CRITICAL" ] || [ "$AJAX_NONCE_SEVERITY" = "HIGH" ]; then

0 commit comments

Comments
 (0)