ANSWERS · CODE SECURITY

How do I audit a PHP codebase with an index instead of grepping 1,400 files?

AUTHOR · XERJ documentation team · REVIEWED BY · XERJ engineering team · PUBLISHED · 2026-08-22 · UPDATED · 2026-08-23

TL;DR — Index the codebase as queryable facts, ask for the bug shape, and read only what comes back. The published WordPress core audit spent about 26,000 tokens. Reading the code once is estimated at about 5,200,000. The headline finding is that core is hardened.

PASTE THIS TO YOUR AI AGENT — IT DOES THE REST Act as a security-reviewing coding agent on a large PHP repository. Read https://xerj.org/llms.txt, compile the invariant you care about into indexed facts with an AST extractor, index those facts into XERJ, query for the violating shape, and read only the survivors — then report the clean surfaces as findings rather than dropping them.
RUN THIS XERJ COMMAND xerj --insecure --data-dir ./.xerj-audit Start a local node to hold the audit facts.
RUN THIS XERJ COMMAND xerj autoindex ./wordpress --url http://127.0.0.1:9200 --prefix wp --state-dir ./state-wp --progress plain Index the source tree itself, for passage retrieval over the code you decide to read.
RUN THIS XERJ COMMAND curl -s -XPOST http://127.0.0.1:9200/wpaudit/_search -H 'content-type: application/json' -d '{"query":{"bool":{"filter":[{"term":{"has_source":"true"}},{"term":{"sinks":"sql"}},{"term":{"sanit":"false"}}]}}}' The published triage query: a request source and a SQL sink in one function with no sanitizer between.
RUN THIS XERJ COMMAND curl -s -XPOST http://127.0.0.1:9200/wphooks/_search -H 'content-type: application/json' -d '{"query":{"term":{"unauth":true}},"_source":["hook","callback"]}' The published entry-surface query: return the unauthenticated hooks in one request.

The published run, and what it is

Everything on this page comes from the WordPress security audit case study and the artifacts under docs/case-studies/wordpress-security-audit/. Nothing here is a new finding, and no vulnerability is named on this page that is not already published there.

The corpus was real WordPress core: 1,492 PHP files, about 619k lines. That is about 26 times a 200k-token context window. "Read everything" is not an available strategy, and chunking it destroys the reach that matters — security bugs live across files.

The negative result is the result

Core came back hardened.

| surface | checked | result | | --- | --: | --- | | authenticated AJAX handlers | 95 | object-scoped, 0 IDOR | | REST permission checks | 90 | object-scoped, 0 IDOR | | file-scope wp-admin handlers | 41 | object-scoped | | auto-triggered deserialization gadgets | — | 0 |

226 handlers swept, zero missing-capability IDOR. That is the finding, and an audit that only reports positives is an audit you cannot trust.

The one real weakness found was wp_http_validate_url failing to block 169.254.169.254, the cloud-metadata address, reachable unauthenticated through pingback_ping. The write-up states it as a known-class limitation that core punts to a filter — not a novel 0-day. It was found by reading the IP-range coverage, because a literal pattern match cannot see an incomplete allow-list. It was then verified by executing the algorithm, and traced to reachability.

The method: compile the invariant, then query it

The pattern generalises past PHP and past WordPress.

  1. Read once to discover the invariant — escaper last, block 169.254, re-verify the object relationship.
  2. Compile it into indexed facts — sanitizer sequences, IP-range coverage, capability object-scoping, and the edges between the functions involved.
  3. Query the facts and get back only the violators, with no code transfer.
  4. Read the survivors to confirm.

Step 2 is where the AST extractors live. The detectors in docs/examples/ast-vuln-graph/ derive the taint and authorization facts; XERJ holds them and answers the query. The published index build was about 3.6 s for 11,990 functions and 1,343 hooks at a 100% tree-sitter-php parse rate.

The two queries that did the work

The SQL-injection triage is one bool filter over the derived taint facts.

curl -s -XPOST 'http://127.0.0.1:9200/wpaudit/_search' \
  -H 'content-type: application/json' \
  -d '{"query":{"bool":{"filter":[
        {"term":{"has_source":"true"}},
        {"term":{"sinks":"sql"}},
        {"term":{"sanit":"false"}}]}}}'

That is "a request source and a SQL sink in the same function with no sanitizer between them", and it narrowed 11,990 functions to 4 worth reading.

The entry surface is one term query over the hook facts.

curl -s -XPOST 'http://127.0.0.1:9200/wphooks/_search' \
  -H 'content-type: application/json' \
  -d '{"query":{"term":{"unauth":true}},"_source":["hook","callback"]}'

It returned 2 of 1,343 hooks: the whole unauthenticated entry surface, in one request.

What grep does better, stated plainly

Grep's literal recall is perfect. The indexed approach's is not.

The published counter-examples are the honest half of this page. A source at file scope is invisible to the extracted facts. A source laundered through $GLOBALS is invisible. The precision detectors have known false-positive drivers — receiver-typed sinks, self-scoped writes, polymorphic capabilities — so every candidate still needs a human or an agent to read it.

The comparison is also not like for like. The SQL triage returns 4 functions; grep returns 51 whole files. Those are different units, and the value of the smaller number is that a person can actually read all of them.

What grep cannot do is express the shape. "Request source and SQL sink in the same function with no sanitizer" is not a literal string, and neither is "unauthenticated hook reaches a sink".

The token accounting, and its caveat

The published figure is about 26,000 tokens for the entire audit against about 5,200,000 to load the code once — about 199x.

Read the caveat with the number. The 26,000 is summed from per-phase measured costs. The 5,200,000 read-everything baseline is a model, an estimate, not an executed run. The whole comparison is token accounting rather than a latency benchmark.

What XERJ is doing here, and what it is not

XERJ does not find bugs. The AST taint and authorization model produces the candidates; XERJ stores them and answers queries over them. Pointed at raw file chunks instead of compiled facts, the security value largely evaporates.

xerj autoindex on the source tree is the other half of the setup: it extracts symbol definitions with tree-sitter, and that extraction is definitions only. The relationship edges the audit queried were derived by the detector scripts, not by autoindex.

The audit also audited XERJ. Two engine defects surfaced mid-run — term over keyword arrays, and boolean term matching — and the sound conclusions were recomputed over pulled _source rather than the buggy path, so they hold. Both are published, and the keyword-array fix landed as a pull request.

Running it on your own repository

Start a node and index the tree. Then run your own detector over the same source to produce the facts you want to query. XERJ is single-node, so this is one process on one machine.

For the retrieval half of the workflow — a cited passage back instead of a whole file — see citing a file rather than pasting the tree. For structural search during a refactor rather than an audit, see syntax-aware code search.

The full prompts, the reproduction scripts and the verification harness are published in the case study. The method ships as a copyable skill, because the taint model is data rather than code.

FAQ

How do I search a large PHP repo for dangerous sinks without reading every file?

Compile the shape you are hunting into indexed facts, then query for the shape. The published audit filtered on a request source, a SQL sink and no sanitizer in one function, and got 4 functions back out of 11,990.

What's a cheaper way than loading WordPress core into context?

Do not load it. The published accounting is about 26,000 tokens for the whole audit against an estimated 5,200,000 to read the code once, because the index decides what is worth reading.

Can an agent audit a codebase from an index?

It can triage from an index. It still has to read the survivors, and the published limitations say so: XERJ stores and queries the candidates, and the AST model is what produces them.

How do I audit a PHP codebase with an index instead of grepping 1,400 files?

Index once, query the bug shape, read only the survivors. Real WordPress core is 1,492 PHP files, and the published run indexed it as queryable facts in about 3.6 s.

Did the audit find a WordPress 0-day?

No, and that is the published result. Core came back hardened: 0 missing-capability IDOR across 226 handlers and 0 auto-triggered deserialization gadgets. The clean negative is the finding.

So what was the one real weakness?

wp_http_validate_url does not block 169.254.169.254, the cloud-metadata address, and it is reachable unauthenticated through pingback_ping. The write-up states it as a known-class limitation core punts to a filter, not a novel 0-day.

Why can't grep express this?

Grep is file-scoped. 'A request source and a SQL sink in the same function with no sanitizer between them' is not a literal string, so grep answers with 51 whole files where the indexed query answers with 4 functions.

Where does grep still win?

On recall. Grep's literal recall is perfect and the model's is not: a source at file scope, or laundered through $GLOBALS, is invisible to the extracted facts. The trade is recall for precision and tokens, and the counter-examples are published.

Evidence

Related