2026-10-12 14:02:41 +02:00
|
|
|
|
# BUG: `taint.sc` single-query path broken by exec-signature skew
|
|
|
|
|
|
|
2026-10-12 14:16:36 +02:00
|
|
|
|
Filed: 2026-10-12 · Repo HEAD at filing: `7ab5c2a` · Severity: **high (silent zero)** · Status: **FIXED (2026-10-12)**
|
|
|
|
|
|
|
|
|
|
|
|
## Resolution (2026-10-12)
|
|
|
|
|
|
|
|
|
|
|
|
Fixed via **option 1** (filter params to the declared signature) in `src/openultrasast/cpg/backend.py`:
|
|
|
|
|
|
|
|
|
|
|
|
- New `_declared_exec_params(source)` parses the `@main def exec(...)` parameter names from a shipped script
|
|
|
|
|
|
(brace-aware, tolerant of an unfindable signature → empty set → "do not filter").
|
|
|
|
|
|
- New memoised `JoernBackend._declared_params(query)` reads+caches a script's declared names.
|
|
|
|
|
|
- `JoernBackend.query()` now drops any param the script does not declare before building the `--param` list
|
|
|
|
|
|
(disposable route) and the in-session named-arg call (session route). This is faithful, not lossy: the
|
|
|
|
|
|
single-request branch of `taint.sc` (line 1542) calls `rowsFor(...)` with top-level params only and lets
|
|
|
|
|
|
`fixedOrigin`/`originAnchors`/`quotedSanitizers`/`guards`/`fieldParameterSources` default — it never could
|
|
|
|
|
|
consume them. The batch path keeps carrying them as JSON the script parses itself.
|
|
|
|
|
|
- No-op for `config`/`dominance`: their own `request_params` emit only params their `.sc` declares (verified),
|
|
|
|
|
|
so nothing is dropped. Only `taint` sheds the 5 extras.
|
|
|
|
|
|
|
|
|
|
|
|
**Verified end-to-end** (patched backend mounted into the replay image, single-query path on banks): the
|
|
|
|
|
|
queries now RUN (no `cpg query taint failed`); baseline = 0 findings, fixed search (annotation) = 2 scored
|
|
|
|
|
|
`path`/`model_entailed` findings (`from_prompt_path` → `prompt_file.write_text(prompt.raw)` L45; `_scan` →
|
|
|
|
|
|
`self._index_path.write_text(...)` L155). Regression test:
|
|
|
|
|
|
`tests/test_cpg_backend.py::test_a_single_query_passes_only_params_the_script_declares` (fake runner, no Joern
|
|
|
|
|
|
— asserts the 5 undeclared params never reach the command line and the query parses).
|
2026-10-12 14:02:41 +02:00
|
|
|
|
|
|
|
|
|
|
## Summary
|
|
|
|
|
|
|
|
|
|
|
|
Any call on the **single-query** taint path — `cpg.run("taint", request_params(...))` — fails on **both**
|
|
|
|
|
|
of its sub-routes, returns `None`, and is read upstream as "no flows / clean". `request_params()` emits five
|
|
|
|
|
|
keys that `taint.sc`'s `@main def exec(...)` does not declare, and the single-query dispatch passes every
|
|
|
|
|
|
param straight to the script. The **batch** path (`run_batch` → `requestsFile` JSON) is unaffected, which is
|
|
|
|
|
|
why the engine-on scans ran while the single-query MVP did not.
|
|
|
|
|
|
|
|
|
|
|
|
This is exactly the failure shape AGENTS.md warns about: a tool that could not read/execute its input reports
|
|
|
|
|
|
a plausible zero that is indistinguishable from a clean result.
|
|
|
|
|
|
|
|
|
|
|
|
## Mechanism
|
|
|
|
|
|
|
|
|
|
|
|
`request_params()` (`src/openultrasast/model/taint.py`) always emits, among others:
|
|
|
|
|
|
|
|
|
|
|
|
- `fieldParameterSources` (line 54)
|
|
|
|
|
|
- `fixedOrigin` (line 65)
|
|
|
|
|
|
- `originAnchors` (line 66)
|
|
|
|
|
|
- `quotedSanitizers` (line 67)
|
|
|
|
|
|
- `guards` (line 68)
|
|
|
|
|
|
|
|
|
|
|
|
`taint.sc`'s signature (`src/openultrasast/cpg/queries/taint.sc:41` `@main def exec(...)`) declares **none**
|
|
|
|
|
|
of those five. Its 19 params are: `cpgFile, sources, sinks, sanitizers, function, file, callDepth,
|
|
|
|
|
|
boundedSinks, parameterSources, fieldSources, evidenceOnly, hookCallbacks, dispatchApply, dispatchValue,
|
|
|
|
|
|
contextFilter, contextPaths, requests, requestsFile, trace`.
|
|
|
|
|
|
|
|
|
|
|
|
`backend.query()` (`src/openultrasast/cpg/backend.py:1266`) dispatches a single query two ways, **both** of
|
|
|
|
|
|
which choke on the undeclared keys:
|
|
|
|
|
|
|
|
|
|
|
|
- **Disposable / CLI route** (lines 1276–1278): appends `--param {key}={value}` for every param. joern errors
|
|
|
|
|
|
`Unknown arguments: --fixedOrigin --guards --quotedSanitizers --originAnchors --fieldParameterSources`
|
|
|
|
|
|
(and, as a knock-on, `Missing argument: --cpgFile`). Observed verbatim via `banks_debug.py` on 2026-10-12.
|
|
|
|
|
|
- **Session route** (lines 1321–1326): defines `def ousast_taint(...)` by renaming `@main def exec(` and then
|
|
|
|
|
|
calls `ousast_taint(key = value, ...)` with named args. The same five keys are not parameters of that
|
|
|
|
|
|
method → the Scala named-arg call fails to compile. (By construction; the rename preserves the exact
|
|
|
|
|
|
`exec` parameter list.)
|
|
|
|
|
|
|
|
|
|
|
|
Why the batch path survives: `run_batch` writes the per-request params **inside** the `requestsFile` JSON
|
|
|
|
|
|
object, and `taint.sc` parses that JSON itself (`requestsJson`, taint.sc:67). The extra fields ride as JSON
|
|
|
|
|
|
keys, never as `exec` arguments, so they are tolerated.
|
|
|
|
|
|
|
|
|
|
|
|
## Blast radius
|
|
|
|
|
|
|
|
|
|
|
|
Broken (single-query taint):
|
|
|
|
|
|
|
|
|
|
|
|
- `taint._usable_flows` (`taint.py:124`) → `verdicts()` (`taint.py:131`) → `verdict()` (`taint.py:99`).
|
|
|
|
|
|
- `pipeline.scan_region` dispatches taint families to `taint_verdict` (`pipeline.py:121`) → so **per-region
|
|
|
|
|
|
single-query taint via `scan_region` is dead** whenever the disposable or session route is taken.
|
|
|
|
|
|
|
|
|
|
|
|
Unaffected:
|
|
|
|
|
|
|
|
|
|
|
|
- The push/scan **batch** path: `scan.py` uses `run_batch("taint", …)` (scan.py:1259, 1264, 1387). This is
|
|
|
|
|
|
why the engine-on pilot produced results.
|
|
|
|
|
|
- `config` and `dominance` single-queries (`config_value.py:46`, `dominance.py:54`) — their `.sc` signatures
|
|
|
|
|
|
are presumed to match what their specs emit (confirm separately; not exercised here).
|
|
|
|
|
|
|
|
|
|
|
|
Open question to close: enumerate every production path that reaches `scan_region`/`verdict()` taint rather
|
|
|
|
|
|
than the batch route, to state whether any shipped push/replay flow silently loses taint flows today, or
|
|
|
|
|
|
whether the single-query path is only hit by pair measurements and ad-hoc harnesses.
|
|
|
|
|
|
|
|
|
|
|
|
## Evidence
|
|
|
|
|
|
|
|
|
|
|
|
- `benchmarks/measurements/2026-10-12-banks-fixed-search-mvp/` — the batch path works (11 flow rows,
|
|
|
|
|
|
`write_text` sinks); the v1 single-query attempt returned 0/0 on `cpg query taint failed`.
|
|
|
|
|
|
- Raw joern error captured by `banks_debug.py` (scratchpad): `Missing argument: --cpgFile; Unknown
|
|
|
|
|
|
arguments: --fixedOrigin --guards --quotedSanitizers --originAnchors --fieldParameterSources`.
|
|
|
|
|
|
|
|
|
|
|
|
## Suggested fix (not yet applied)
|
|
|
|
|
|
|
|
|
|
|
|
Pick one, lowest-risk first:
|
|
|
|
|
|
|
|
|
|
|
|
1. **Make `backend.query()` filter params to the script's declared signature** before building `--param` /
|
|
|
|
|
|
named-arg lists (parse the `@main def exec(` arg names once, intersect). Fixes every current and future
|
|
|
|
|
|
signature skew for all single-query scripts, symmetrically with how the batch path already tolerates
|
|
|
|
|
|
extras. Preferred.
|
|
|
|
|
|
2. Add the five params to `taint.sc`'s `exec` signature as ignored passthroughs (mirrors batch semantics but
|
|
|
|
|
|
duplicates the JSON parsing already in the script; narrower).
|
|
|
|
|
|
3. Route `_usable_flows` through `run_batch` with a one-element request (makes single-query taint share the
|
|
|
|
|
|
tolerant path; changes the call shape upstream).
|
|
|
|
|
|
|
|
|
|
|
|
Add a regression test under the joern-gated suite: `cpg.run("taint", request_params(path_spec, …))` must
|
|
|
|
|
|
return a parsed payload (not `None`) on a fixture CPG, asserting the instrument actually executed.
|