mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 20:49:28 +00:00
Two design questions answered by the entailment measurement. IL, not AST. Our semantic/ir.py is a lossy AST projection -- flat call/bind lists, no CFG, no PDG -- which is *why* entailment is 2.2%. Guard dominance is a dominator-tree question and path-sensitive taint needs branch structure; a flat bind list expresses neither. Real abstract interpretation is a fixpoint over a CFG, so the substrate is a Code Property Graph: AST + CFG + PDG + call graph in one queryable graph, taint as source->sink-minus-sanitizer reachability, absence as dominance. Adopt, not build. Building a multi-language CFG/PDG/taint engine on tree-sitter is reinventing CodeQL/Semgrep/Joern -- the maximal case of this session's failure mode. Joern is Apache-2.0, covers our exact corpus (C/C++/Java/JS/Python/Kotlin/binary), has a taint engine, is embeddable, and a CPG+LLM MCP bridge already exists. The 2.2% on our flat IR is the measured distance to a real CPG, and closing it by hand is reimplementing Joern. The flow: source -> Joern CPG -> taint + dominance -> deterministic entailed/corroborated verdict -> LLM judges the residual, checked against the CPG. What stays ours is the contribution: the taxonomy and the source/sink/sanitizer/guard models for the web/logic CWEs, ported to CPG queries, plus the LLM-checked-against-the-model orchestration. Joern is a workbench with no opinion on these classes; that opinion is our layer. Named cost: Joern is JVM/Scala, so server-mode or the MCP bridge, not in-process. Three-layer adoption now: Joern CPG for the web/logic arbiter, Clearwing for the memory/C execution tier, and our security modelling + LLM orchestration as the layer that binds them. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
292 lines
22 KiB
Markdown
292 lines
22 KiB
Markdown
# Corrective brief: model-grounded detection
|
||
|
||
> **Renamed from `model-grounded-detection` after the maintainer's correction (2026-09-08):** "execution
|
||
> isn't possible for all types of projects. I'd see a model layer instead, which can be built up from code.
|
||
> given that we are in a SAST project. I see dynamic testing later, but only if it's needed." Execution is one
|
||
> arbiter, and the easy one only for memory-safety-in-C. This project's arbiter is a **program model built
|
||
> deterministically from code**; execution is a later, conditional tier. The correction is integrated below.
|
||
|
||
**Status:** course-setting. Supersedes `learning-harness` and `constrained-detector`. Not a requirements
|
||
document — a decision about what to build, what to delete, and what to adopt, written after the
|
||
learning-harness line produced numbers its own measurements say to discard.
|
||
|
||
## 1. The premise we got wrong
|
||
|
||
OpenUltraSAST was built on an unstated assumption: that an LLM is a *noisy oracle* whose judgement must be
|
||
averaged (K runs), floored (per-family noise budget), gated (sign test), and slowly improved (evolve loop).
|
||
Every mechanism in `learning-harness` follows from that assumption. This session measured all of it and it
|
||
does not hold:
|
||
|
||
- Five evolve rounds, $2.17, **zero decisions distinguishable from chance** — because the acceptance rule
|
||
compares two ensemble votes over a detector that never runs the same investigation twice.
|
||
- 120 runs of one detector produced **120 distinct trajectories**. The variance is the loop's freedom to
|
||
search, not the model's uncertainty about an answer.
|
||
- The noise floor a regression budget is derived from measures *how often an unconstrained search happens
|
||
to reach the sink* — so the worse the search wanders, the more collateral damage a round is licensed to do.
|
||
|
||
The assumption is also empirically false as a claim about LLMs, and the evidence is now overwhelming:
|
||
|
||
- **Google Big Sleep** (Nov 2024): the first LLM-discovered zero-day in widely-used real-world software — a
|
||
stack buffer underflow in SQLite — found by *variant analysis*, a sandboxed test case, and an
|
||
auto-generated root-cause report. Not pattern-matching; reproduction.
|
||
- **DARPA AIxCC finals** (DEF CON 33, Aug 2025): seven autonomous systems processed 54M lines of code,
|
||
patched 43 of 54 synthetic vulnerabilities and **uncovered 18 previously-unknown real 0-days**. Buttercup
|
||
(Trail of Bits, 2nd place) found **28 vulnerabilities across 20 CWEs at 90% accuracy, $181 per point,
|
||
using non-reasoning LLMs only**, ~$152 per task, 45 minutes each.
|
||
|
||
The lever in every one of those systems is **architecture, not prompt optimization**. Buttercup won with
|
||
non-reasoning models. Nobody evolved a prompt with a genetic loop. The question "can LLMs find security
|
||
bugs" was answered *yes* by 2024; we spent this project answering a question the field had closed, with the
|
||
wrong architecture.
|
||
|
||
## 2. What the successful systems share, and Clearwing is the open one
|
||
|
||
Big Sleep, Atlantis (AIxCC 1st), Buttercup (2nd), and **Clearwing** (`/home/mc/Source/clearwing`,
|
||
Lazarus-AI, MIT, an open reproduction of Anthropic's Glasswing) share one shape:
|
||
|
||
> **The LLM is a judgement layer on top of a deterministic substrate that arbitrates its claims. Truth comes
|
||
> from that substrate, not from a score against a labeled corpus.**
|
||
|
||
In the memory-safety systems above the substrate is *execution* — a crash is the arbiter. But execution is
|
||
only the arbiter you reach for when the target builds in isolation and the bug crashes, which is the C world,
|
||
not ours. The general form of the substrate is a **program model built from the code**: a deterministic
|
||
abstraction rich enough to *check* a claim — does the source actually reach the sink through resolved
|
||
dataflow; is there a guard on the path or isn't there; is the entry point attacker-reachable. Execution is one
|
||
way to arbitrate; a model is the way that works for a SAST project and for bugs that never crash.
|
||
|
||
Clearwing's evidence ladder is the whole thesis in one type:
|
||
|
||
```
|
||
suspicion → static_corroboration → crash_reproduced → root_cause_explained
|
||
→ exploit_demonstrated → patch_validated
|
||
```
|
||
|
||
The load-bearing gate is `poc_runner.py`: apply a candidate diff inside the sandbox, recompile, re-run the
|
||
PoC, and return a boolean for *does the crash still happen*. That single primitive replaces our entire
|
||
noise architecture. You do not average five noisy runs when a container can tell you whether the bug is
|
||
real. **We built K-runs and noise floors because we had no ground truth. They generate their own.**
|
||
|
||
## 3. What Clearwing does better, mapped to what failed here
|
||
|
||
| Our failure this session | What Clearwing does instead |
|
||
|---|---|
|
||
| K-runs / noise floor / sign test / regression budget — a statistical treatment for a structural problem | **Execution is the arbiter.** `poc_runner` reproduces or it doesn't. `stability.py` runs the PoC across N fresh containers (≥90% = stable) to kill flakiness at the source instead of averaging it. |
|
||
| Evolve loop: 5 rounds, 0 reliable decisions, $2.17 to learn nothing | **A fixed specialist roster** dispatched by file tags — `memory_safety`, `kernel_syscall`, `crypto_primitive`, `web_framework`, `logic_auth`, `general` — plus **variant analysis** (verify a bug, generate a grep+semantic pattern, find its structural twins, iterate to fixpoint). Density compounds *within a run*, no optimizer. |
|
||
| Second judge / fusion, gesturing at independence | **4-axis adversarial validator** (REAL / TRIGGERABLE / IMPACTFUL / GENERAL) that sees *only* the finding, PoC and exploit — never the discovery transcript — steel-mans both sides, and **must attempt reproduction**. |
|
||
| Corpus: 12 hand-harvested function excerpts per family, scored by text-inside-function, capped by twins/straddles/truncation defects we spent days repairing | **Corpus is real repos at vulnerable-parent commits + OSS-Fuzz** (7000 targets in `full` mode), run blind, with the fix commit used only as an *after-the-fact oracle*. Truth is generated by the sandbox, not annotated by us. `CampaignConfig` scales this to hundreds of repos with shared budget, checkpoint/resume, and diminishing-returns detection. |
|
||
| Detector chooses what to look at (555 grep calls vs 71 entry-point lookups) | **Deterministic preprocessing feeds candidates:** callgraph → intra-procedural taint (source→sink) → 3-axis ranker → hunter. The model judges; it does not search for the sink. This is exactly the `constrained-detector` design we had started — Clearwing is the mature version of it. |
|
||
|
||
## 4. The one thing OpenUltraSAST proved that Clearwing lacks
|
||
|
||
This is not a total loss, and the course is not "throw everything away." Two results here are real,
|
||
measured, and *absent from Clearwing*:
|
||
|
||
1. **IR-driven candidate enumeration with a published ceiling.** We measured that the pattern ruleset caps
|
||
recall at 12.1% while call-sites-and-bindings from the semantic IR reach **96.6%** across the web slices,
|
||
a median of ten sites per function. Clearwing's candidate feeder is C-first (taint tables for
|
||
`memcpy`-shaped bugs); its `web_framework` specialist is a *single unconstrained prompt* with no
|
||
equivalent candidate enumerator for logic/web classes.
|
||
|
||
2. **Absence-bug detection.** Our `obligations` work — an access-control bug is the *absence* of a guard
|
||
around an operation its siblings protect — has no analogue in Clearwing, whose ladder is built around a
|
||
*crash*. An IDOR or a missing authorization check never crashes a sandbox. Clearwing's execution
|
||
ground-truth model does not reach these classes at all, and they are exactly the "dangerous but
|
||
low-hanging" web bugs this project was chartered to catch.
|
||
|
||
So the gap Clearwing leaves open is **the web/logic taxonomy without a crash oracle** — which is where our
|
||
IR candidate enumerator, our family taxonomy, and our obligation checker are genuinely ahead.
|
||
|
||
## 4b. The model layer, and the evidence ladder it produces
|
||
|
||
The arbiter is a program model constructed deterministically from the code — the thing an LLM claim is
|
||
*checked against*, so that a finding's truth does not rest on the LLM that proposed it. We already hold the
|
||
first rungs of it; they were buried under a noise layer instead of being made the arbiter.
|
||
|
||
The evidence ladder is the model layer made legible. Each rung is a stronger, still-deterministic statement:
|
||
|
||
| rung | what it means | who establishes it | we have |
|
||
|---|---|---|---|
|
||
| `suspicion` | the model enumerated a candidate site; the LLM flagged it | IR enumeration + LLM | yes (candidate enumerator, 96.6% ceiling) |
|
||
| `model_corroborated` | the LLM's claim is *consistent* with the model — the source it names reaches the sink it names through a resolved binding/dataflow path; or the operation it flags lacks a discharging guard its siblings carry | model checks the LLM's assertion | partial (binding chains, obligation facts) |
|
||
| `model_entailed` | the model *itself* establishes it, independent of trusting the LLM — a concrete source→sink path with no sanitizer node; a provably-absent guard among a sibling set that carries it | model alone | to build (interprocedural dataflow + guard dominance) |
|
||
| `execution_confirmed` | a sandbox reproduced it — a crash, or a differential request the fixed twin rejects | execution substrate (deferred tier) | Clearwing, later, only where needed |
|
||
|
||
Two properties are the whole point:
|
||
|
||
- **`model_corroborated` and above are deterministic.** Run the checker twice over the same model and it
|
||
returns the same verdict. That removes the run-to-run noise the same way a crash would — no K-runs, no
|
||
floor, no sign test — *without needing a runnable target*. The noise this session measured was the LLM
|
||
free-searching; a model check does not search.
|
||
- **The LLM's job shrinks to the residual the model cannot decide** — is this string genuinely
|
||
attacker-controlled, is this guard semantically sufficient — and even that answer is checked against the
|
||
model rather than averaged over runs. The LLM stops being the oracle and becomes a proposer the model
|
||
audits.
|
||
|
||
**Measured, 2026-09-08 — the entailment ceiling** (`benchmarks/measurements/2026-09-08-model-entailment-ceiling.json`,
|
||
offline, no LLM):
|
||
|
||
| rung | web/logic taxonomy | for comparison |
|
||
|---|---|---|
|
||
| `model_entailed` today | **2.2%** (2/92) | — |
|
||
| `model_corroborated`, one-hop taint | **24%** (22/92) | — |
|
||
| taint reachable, multi-hop (flow families) | **35%** (26/74) | — |
|
||
| candidate site enumerable | — | **96.6%** (constrained-detector group 1) |
|
||
|
||
The gap between 96.6% (a site exists) and 2.2% (the model *decides* the vulnerability) is the gap between
|
||
enumerating and arbitrating, and it is the whole finding. The model cannot be a *pure* arbiter at v1. But it
|
||
is not a wall — the measurement diagnoses depth, not impossibility: one-hop source resolution (real taint sits
|
||
1–3 binds upstream: `q = "…" + id; execute(q)`), a closed literal sink table (misses project wrappers and the
|
||
path/xss/prototype sinks), and the function-naming gap that eats 20/50 injection pairs as `no_function`.
|
||
Deepening the taint to reuse the candidate enumerator's depth-4 chain already moved injection 24% → 48%.
|
||
|
||
So the architecture the numbers support is a **layered arbiter**, not a pure one:
|
||
|
||
- where the model **entails** — report `model_entailed`, deterministic, the LLM not needed;
|
||
- where the model **corroborates a taint path** (~35% and rising) — the LLM judges, but its claim is *checked*
|
||
against the resolved path, so run-to-run noise is removed on this band;
|
||
- where the model can only **enumerate** (the rest, up to 96.6%) — honest `suspicion`, the LLM proposes with
|
||
no deterministic backstop, and this is exactly the band where the deferred execution / differential tier
|
||
earns its place.
|
||
|
||
Pure `model_entailed`-as-arbiter is the north star. The v1 is the layered arbiter, and the near-term work is
|
||
named by the measurement: fix function naming, build per-family sink/sanitizer tables, and lift the flow
|
||
model onto the multi-hop binding chain we already have.
|
||
|
||
This is the classical SAST arbiter — dataflow / abstract interpretation, the CodeQL and Infer lineage —
|
||
with the LLM supplying the semantic judgement the model is weakest at (aliasing intent, whether a value is
|
||
really untrusted, whether a guard really covers the case). It does not need soundness. A partial model that
|
||
can confirm *some* claims and honestly returns "could not confirm — stays suspicion" for the rest is already
|
||
the independent arbiter we never had.
|
||
|
||
## 4c. IL or AST, build or adopt — the substrate decision
|
||
|
||
**IL, and adopt one.** Both halves are forced by the entailment measurement.
|
||
|
||
*Why an IL, not the AST we have.* Our `semantic/ir.py` is not an IL — it is a lossy AST projection: per-function
|
||
flat lists of calls and binds, no control-flow graph, no basic blocks, no data-dependence edges. That is
|
||
*why* entailment sits at 2.2%. Real abstract interpretation is a fixpoint over a **CFG**: guard *dominance*
|
||
(is the check on every path to the operation) is a dominator-tree question, and path-sensitive taint (a
|
||
sanitizer on the taint path specifically) needs the branch structure. A flat bind list cannot express either;
|
||
it can only over-approximate taint flow-insensitively by name, which is the one-hop ceiling we measured. The
|
||
model layer wants the classical IL that merges AST + CFG + PDG + call graph into one graph — a **Code Property
|
||
Graph (CPG)** — over which taint is a reachability query (source → sink minus sanitizer) and absence is a
|
||
dominance query.
|
||
|
||
*Why adopt, not build.* Building a multi-language CFG/PDG/taint engine on top of tree-sitter is precisely
|
||
reinventing CodeQL, Semgrep and Joern — a multi-year effort, and the maximal case of the failure mode this
|
||
session logged eighteen times. **Joern** is the adoptable answer: Apache-2.0, a CPG engine for exactly our
|
||
corpus (C/C++/Java/JavaScript/Python/Kotlin/binary), a built-in taint-propagation engine, embeddable, and
|
||
there is already a published CPG-plus-LLM bridge (Codebadger, an MCP server over Joern) and an arXiv line on
|
||
CPGs as the substrate for LLM program analysis. Joern *is* "a model built up from code" as a graph — the exact
|
||
thing the maintainer described. The alternatives: CodeQL is more powerful but least adoptable (restrictive
|
||
license, query-only, GitHub-owned); Semgrep's taint engine is lighter and we already have integration
|
||
precedent through Clearwing's sidecar, but interprocedural taint is behind the paid Pro engine.
|
||
|
||
*The flow, concretely:*
|
||
|
||
```
|
||
source ──▶ Joern: compile to CPG (one IL: AST + CFG + PDG + call graph, language-agnostic)
|
||
──▶ taint reachability (source→sink − sanitizer) + CFG dominance (absence)
|
||
──▶ deterministic verdict: model_entailed | model_corroborated | (neither) suspicion
|
||
──▶ LLM judges the residual, its claim CHECKED against the CPG
|
||
```
|
||
|
||
The CPG is language-agnostic once its frontend exists, so the taint and dominance logic is written *once*, not
|
||
per language — which is the whole reason our per-language tree-sitter approach kept hitting naming and grammar
|
||
gaps.
|
||
|
||
*What stays ours, and is the contribution.* Joern is a workbench, not an opinionated web/logic detector with
|
||
an LLM judge. What we own and port onto it: the closed **family taxonomy**; the **source / sink / sanitizer /
|
||
guard models** for the web/logic CWEs — our obligation facts and closed guard vocabulary become CPG queries
|
||
and taint specifications; and the **LLM-adjudication-checked-against-the-CPG** orchestration. That security
|
||
modelling — expressed as queries over an adopted CPG — is where our work is genuinely ahead of both Joern
|
||
(no opinion on these classes) and Clearwing (crash-oracle, no arbiter for bugs that do not crash).
|
||
|
||
*The one real cost.* Joern is JVM/Scala; embedding it in a Python tool means its server mode or the MCP bridge,
|
||
not an in-process call. That is an integration cost, named, and far below the cost of building the engine.
|
||
|
||
## 5. The course
|
||
|
||
**Build the model layer up from the code as the arbiter; make the LLM a proposer the model audits; delete the
|
||
noise architecture; keep execution as a later, conditional tier.**
|
||
|
||
Concretely, superseding both prior specs:
|
||
|
||
1. **Delete, do not fix.** The K-run machinery, the per-family noise floor, the regression budget, the sign
|
||
test, the evolve loop, the acceptance rule, the mechanism-lever optimizer. All of it existed to average an
|
||
LLM oracle we could not otherwise trust. A deterministic model arbiter removes the need for every piece.
|
||
`learning-harness` Req 3.5, 8.2, 9.5 and the evolve tasks are retired, not amended.
|
||
|
||
2. **Adopt a CPG engine (Joern) as the arbiter substrate; port our security models onto it.** We do not lift
|
||
our flat IR to a CFG ourselves (§4c). The taxonomy, the source/sink/sanitizer/guard models, and the
|
||
obligation/absence logic move from prompt context to **CPG taint specifications and dominance queries** —
|
||
deterministic `model_corroborated` and `model_entailed` verdicts over an engine that already has the CFG
|
||
and PDG our IR lacks. Our tree-sitter IR retains one job: cheap candidate enumeration for the `suspicion`
|
||
band where the CPG has no verdict.
|
||
|
||
3. **The LLM proposes; the model disposes.** One bounded, typed question per candidate — the constrained
|
||
detector we already started — but the answer is not scored against a corpus, it is *checked against the
|
||
model*. A claim the model corroborates advances; a claim it contradicts is dropped with the contradiction
|
||
recorded; a claim it can neither confirm nor deny stays `suspicion` and is reported as such. No K-runs, no
|
||
evolve loop.
|
||
|
||
4. **Corpus is the model's calibration set, not the LLM's training signal.** Keep the vulnerable-parent /
|
||
fix-commit pairs — but their role changes. The pair is ground truth for *the model's precision*: the fix
|
||
is what the model should be able to see (a guard added, a sanitizer inserted, a bound checked), and a pair
|
||
the model cannot distinguish is a gap in the model, named and measured, not a detector miss to average.
|
||
Harvest more pairs at repository scale from CVE history, as Clearwing does; truth is the fix diff read by
|
||
the model, not a label we hand-assign.
|
||
|
||
5. **Execution is a deferred, conditional tier.** For the classes a static model genuinely cannot arbitrate —
|
||
memory-safety-in-C, where reachability and exploitability turn on runtime layout — a sandbox crash oracle
|
||
is the right arbiter, and Clearwing already is that system. Adopt it *there*, later, only when a class
|
||
needs it. It is the `execution_confirmed` rung, not the foundation.
|
||
|
||
6. **Reporting keeps the honesty disciplines that stand:** per-slice numbers with the real-world slice
|
||
deciding, the overfitting gap published, and the evidence rung on every finding — now meaning a model
|
||
verdict, not a sandbox result.
|
||
|
||
## 6. Build vs. adopt, revised
|
||
|
||
The earlier draft of this brief over-rotated to "adopt Clearwing wholesale," because the external evidence
|
||
foregrounds execution. The maintainer's correction re-centres it, and the honest split is cleaner than either
|
||
extreme:
|
||
|
||
- **The model layer is adopt-plus-contribute, not build.** The arbiter *engine* is Joern's CPG (Apache-2.0,
|
||
our languages, taint + dominance built in); the *contribution* is our security modelling on top of it — the
|
||
taxonomy and the source/sink/sanitizer/guard specs for the web/logic classes, plus the LLM adjudication
|
||
checked against the CPG. The entailment measurement (2.2% on our flat IR) is what rules out building the
|
||
engine ourselves: that number is the distance between our AST projection and a real CPG, and closing it by
|
||
hand is reimplementing Joern.
|
||
- **The execution tier is Clearwing's to adopt**, later, for the memory-safety classes where it is strong and
|
||
we are weak — via the fork the maintainer already maintains. We do not reimplement its sandbox lifecycle,
|
||
container pooling, sanitizer images or PoC stability; those are exactly the parts this session proved we get
|
||
wrong.
|
||
|
||
So neither "rebuild everything" nor "throw our work away." Build the arbiter we can build from code; adopt the
|
||
arbiter we cannot, when a class demands it.
|
||
|
||
## Open questions for the requirements phase
|
||
|
||
- **How far up the ladder can a partial model reach without soundness?** `model_corroborated` needs only a
|
||
consistency check; `model_entailed` needs a real interprocedural dataflow/guard-dominance result. What
|
||
fraction of our corpus can the model *entail* today, and where does it honestly fall back to `suspicion`?
|
||
This is the first measurement to run — the model analogue of the 96.6% candidate ceiling — and it can end
|
||
or reshape the design before any LLM cost, exactly as the ceiling did.
|
||
- **What is the model's arbiter for an absence bug?** A missing guard has no dataflow path to confirm. The
|
||
obligation/sibling-dominance model is the candidate arbiter; does it distinguish the vulnerable-parent from
|
||
the fixed twin across the corpus on its own, deterministically?
|
||
- **Where does the LLM's residual judgement resist checking?** Some claims — "this value is attacker-
|
||
controlled by convention", "this guard is semantically insufficient" — the model cannot audit. How large is
|
||
that residual, and is `suspicion` an acceptable home for it or does it need the execution tier sooner?
|
||
- **Which classes cross into execution, and when.** Memory-safety-in-C is the clear case for the deferred
|
||
tier. Is anything in the web/logic taxonomy genuinely un-arbitrable by a model, forcing the differential
|
||
request oracle earlier than "later, if needed"?
|
||
- **What of the existing corpus survives** as the model's calibration set, given the twin/straddle/tier
|
||
defects the learning-harness reviews surfaced?
|
||
|
||
Sources: [Big Sleep / SQLite zero-day](https://thehackernews.com/2024/11/googles-ai-tool-big-sleep-finds-zero.html) ·
|
||
[AIxCC finals — Buttercup, 28 vulns / 20 CWEs / non-reasoning LLMs](https://blog.trailofbits.com/2025/08/09/trail-of-bits-buttercup-wins-2nd-place-in-aixcc-challenge/) ·
|
||
[AIxCC results — 18 real 0-days, 43/54 patched](https://semiengineering.com/aixcc-2025-what-it-means-for-device-security/) ·
|
||
[Clearwing](https://github.com/Lazarus-AI/clearwing) · local: `/home/mc/Source/clearwing`, fork `norandom/deepwing`.
|