feat(model-grounded-detection): the safe-shape test, without which the model arbitrates nothing

The first live run produced the finding that shapes this design: taint
reachability alone does not distinguish a fix from a bug. On the canonical SQL
pair the flow request.args["name"] -> name -> execute exists on BOTH sides --

  vuln   db.execute("select ... '" + name + "'")   one argument, interpolated
  fixed  db.execute("select ... = %s", (name,))    two arguments, bound

-- so the model entailed the fixed twin exactly as readily as the vulnerable
one, and arbitrated nothing. What separates them is the SHAPE of the sink call,
which is precisely what safe_shape_sinks preserved from the earlier sanitizer
fix. The query now reports sinkArity and sinkArg0Literal, and a flow ending at
a sink written in its declared-safe form is not a vulnerability.

Two bugs in that shape test, both found only against the real engine:
Joern's `call.argument` includes the RECEIVER at argumentIndex 0, so `db` was
counted as an argument -- making a one-argument interpolated call look like a
two-argument bound one, the exact inversion of the test -- and the "first
argument" whose literalness decides a format-string sink was the receiver
rather than the format. Both fixed with argumentIndexGt(0).

The rule is narrow on purpose: only sinks named in safe_shape_sinks are subject
to it (os.system(x) is not safe merely because it takes one argument), and a
row carrying no shape fields is judged on the flow alone -- an unknown shape
must never be read as a safe one.

End to end on the pair, deterministic and with no model call:
  vuln  -> model_entailed  request.args["name"] -> db.execute(...) (line 4)
  fixed -> None

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-08 12:51:42 +02:00
co-authored by Claude Opus 5
parent f59e0f5b5a
commit 1765e51da1
4 changed files with 123 additions and 9 deletions
@@ -135,7 +135,7 @@ queries in group 4). The gates, `redaction.py`, `pairs.py` overlay/inventory pat
## Group 2 — The Joern spike on one family (go/no-go)
- [ ] 2.1 The CpgBackend seam, out of process
- [x] 2.1 The CpgBackend seam, out of process
- `src/openultrasast/cpg/capability.py::has_cpg()` — Joern on PATH (probe `joern-parse --help`), the
`has_semantic_extra` pattern exactly. `cpg/backend.py`: `CpgResult` (a built `cpg.bin` path + a `run(query,
params)->JSON` callable); `JoernBackend` (subprocess: `joern-parse <root>` then `joern --script
@@ -148,7 +148,7 @@ queries in group 4). The gates, `redaction.py`, `pairs.py` overlay/inventory pat
- _Requirements: 4.1, 4.2, 4.3, 4.5, 11.3_
- _Depends: 1.6_
- [ ] 2.2 The injection taint query and its verdict
- [x] 2.2 The injection taint query and its verdict
- `cpg/queries/taint.sc` — a CPGQL script parameterised by a `TaintSpec` (sources, sinks, sanitizers) that
emits, per candidate, the JSON `{sink, source, path, sanitized: bool}`. `model/taint.py::verdict(cpg,
spec, candidate)` → `ENTAILED` when a source→sink path exists with `sanitized=false`, `CORROBORATED` when