Files
Open_Ultra_SAST/.kiro/specs/engine-change-scoped-analysis/bugs/taint-sc-single-query-signature-skew.md
T
Marius CiepluchandClaude Opus 4.8 a024656da0 fix(cpg): single-query path drops params the script does not declare
taint.request_params emits guards/fixedOrigin/originAnchors/quotedSanitizers/
fieldParameterSources, which taint.sc reads from per-request JSON in its BATCH
branch but does NOT declare on `@main def exec` and does NOT consume in its
single-request branch (it defaults them). The single-query path passed them as
`--param guards=...` (disposable) or named args (session), so joern errored
`Unknown arguments` and every cpg.run("taint", ...) failed -- a silent zero of
the shape AGENTS.md warns about. scan_region->taint_verdict->_usable_flows and
the pair-path verdict()/verdicts() were dead; the push/scan batch route was
unaffected (it carries the extras as JSON the script parses itself).

Fix: parse the script's `@main def exec(...)` signature once (_declared_exec_params
+ memoised _declared_params) and, in query(), filter params to the declared set
before building either route. Faithful, not lossy -- the single-request branch
could never use those 5 fields. No-op for config/dominance (their request_params
emit only declared params; verified). Only taint sheds the 5 extras.

Verified end-to-end on banks (patched backend in the replay image): single-query
path now runs; baseline 0 findings, annotated search 2 path/model_entailed
findings (prompt->write_text). Regression test asserts no undeclared param
reaches the command line and the query parses, with a fake runner (no Joern).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-10-12 14:16:36 +02:00

6.5 KiB
Raw Blame History

BUG: taint.sc single-query path broken by exec-signature skew

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

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.