<!-- generated by build_articles.py — edit content/answers/audit-php-codebase-with-an-index.md instead -->
---
title: "Audit a PHP codebase with an index, not grep"
canonical: "https://xerj.org/answers/audit-php-codebase-with-an-index"
updated: "2026-08-23"
source: "content/answers/audit-php-codebase-with-an-index.md"
---

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

**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.

## Agent prompt

```text
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.
```

## Commands

### Command 1

Note: Start a local node to hold the audit facts.

```sh
xerj --insecure --data-dir ./.xerj-audit
```

### Command 2

Note: Index the source tree itself, for passage retrieval over the code you decide to read.

```sh
xerj autoindex ./wordpress --url http://127.0.0.1:9200 --prefix wp --state-dir ./state-wp --progress plain
```

### Command 3

Note: The published triage query: a request source and a SQL sink in one function with no sanitizer between.

```sh
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"}}]}}}'
```

### Command 4

Note: The published entry-surface query: return the unauthenticated hooks in one request.

```sh
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 run, and what it is

Everything on this page comes from the [WordPress security audit case study](/case-studies/wordpress-security-audit) 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.

```sh
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.

```sh
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](/answers/code-search-mcp-for-claude-code). For structural search during a refactor rather than an audit, see [syntax-aware code search](/answers/syntax-aware-code-search-refactoring).

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

- The published audit covered real WordPress core: 1,492 PHP files and about 619k lines, indexed as 11,990 functions and 1,343 hooks in about 3.6 s at a 100% tree-sitter-php parse rate. — `docs/case-studies/wordpress-security-audit/README.md`
- Token accounting: about 26,000 tokens for the entire audit against a read-everything baseline of about 5,200,000 tokens, summed from per-phase measured costs; the baseline is a model, not an executed run. — `landing/case-studies/wordpress-security-audit.html`
- The SQL-injection triage narrowed 11,990 functions to 4 candidates worth reading, where grep hands back 51 whole files; the units differ and grep's literal recall is perfect. — `docs/case-studies/wordpress-security-audit/REPRODUCE.md`
- 226 handlers were swept - 95 authenticated AJAX, 90 REST and 41 file-scope wp-admin - with 0 missing-capability IDOR, and the deserialization hunt found 0 auto-triggered gadgets. — `docs/case-studies/wordpress-security-audit/FINDINGS.md`
- One query returned the entire unauthenticated entry surface: 2 of 1,343 hooks. — `docs/case-studies/wordpress-security-audit/AUTHENTICATION.md`
- The one real weakness found was that wp_http_validate_url does not block 169.254.169.254, reachable unauthenticated through pingback_ping - a known-class limitation core punts to a filter, not a novel 0-day. — `docs/case-studies/wordpress-security-audit/FINDINGS.md`
- XERJ does not find bugs: the AST taint and authorization model produces the candidates and XERJ stores and queries them; pointed at raw file chunks instead of facts, the security value largely evaporates. — `docs/case-studies/wordpress-security-audit/README.md`
- tree-sitter extraction in autoindex is definitions only - no imports, no call graph, no cross-file references. — `docs/examples/ast-vuln-graph`

## Related

- [How do I run syntax-aware code search?](/answers/syntax-aware-code-search-refactoring)
- [How do I stop my agent from reading the whole repo into context?](/answers/index-monorepo-for-agent)
- [I need my agent to find a passage and cite the file, not dump the whole tree. How?](/answers/code-search-mcp-for-claude-code)
- [My codebase indexer says indexed but I don't see my code. How do I check it actually finished?](/answers/check-codebase-index-is-complete)
- [WordPress security audit](/case-studies/wordpress-security-audit)
