feat(model): a bound on the data lowers the rung, it does not clear it

contributor-scan 2.12. c/injection and c/memory carried zero sanitizers,
so no C flow could ever be discharged: every `argv -> strcpy` entailed
however carefully it was bounded.

Which sinks can be discharged this way is read from the sink fact's own
CWE rather than declared: CWE-121 is a stack buffer overflow, so
gets/sprintf/strcat/strcpy qualify. It is the only overflow CWE in the
fact tables and it is C-only, so no Python or JavaScript sink is touched
and the pair corpus is unaffected -- gates byte-identical.

Not a sanitizer list, because `strlen` appears in the guarded and the
unguarded case alike; the discharge is a guard, not a call.

**The first implementation required the bound to DOMINATE the sink, and it
found nothing.** libpng guards `strcpy(outname+len, ".png")` with
`(len = strlen(inname)) > 250`, but that check sits on an else-if chain
whose sibling branch skips it entirely, and the protection reaches the
sink through an `error` flag: `++error` in the guarded branch, `if
(!error)` around the copy. Joern's dominator set for that sink has 89
calls and correctly excludes the bound; `controlledBy` returns none
either. Verified against the CPG before the rule was changed rather than
after it failed.

So the bound is searched in the method, and the consequence is
corroborated rather than silence. That is the honest rung: a bound on this
variable means the graph has NOT shown the copy is unguarded, and it has
not shown it is guarded either -- off-by-one being how bounds fail. The
judge is asked. Proving the flag-mediated case needs path-sensitive
reasoning this arbiter does not do.

  libpng entailed   3 -> 1

The survivor is also a false positive: `strcpy(output + len, ".icc")` in
iccfrompng.c, where `output = malloc(len + 5)` makes it fit exactly. That
is a different discharge shape -- allocated to size, not bounds-checked --
and it is recorded as 2.13 rather than chased. Both shapes were found on
one repository, and extending a rule until that repository is clean is the
overfitting Req 8 exists to prevent; 2.13 requires a second C checkout
with a genuine CWE-121 so the change can be measured for what it silences
as well as what it clears.

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-09 13:01:30 +02:00
co-authored by Claude Opus 5
parent 6dc25d77e4
commit 9221ba8e40
6 changed files with 181 additions and 9 deletions
+48
View File
@@ -415,3 +415,51 @@ def test_a_region_scopes_its_question_to_its_own_file() -> None:
assert config["file"] == "config.py"
# The pair path stays unscoped, so the committed pair measurements remain reproducible.
assert taint_params(taint_specs(language="python")["injection"], function="run")["file"] == ""
def _bounded_rows(bounded: bool): # type: ignore[no-untyped-def]
return [
{
"sink": 'strcpy(outname+len, ".png")',
"sinkLine": "374",
"sinkMethod": "main",
"sinkFile": "contrib/gregbook/wpng.c",
"source": "*argv",
"sanitized": False,
"length": 6,
"sinkArity": 2,
"sinkArg0Literal": False,
"inLabeledScope": True,
"bounded": bounded,
"bound": "(len = strlen(inname)) > 250" if bounded else "",
}
]
def test_a_bound_that_governs_the_sink_lowers_the_rung() -> None:
"""contributor-scan 2.12. libpng's `strcpy(outname+len, ".png")` is governed by `len > 250` against a
char[256]. Entailing it is a claim the code contradicts -- but a bound is not proof either, since
off-by-one is how they fail, so the judge is asked rather than the site silenced."""
from openultrasast.cpg.backend import CpgResult
from openultrasast.model.ladder import Rung
from openultrasast.model.specs import taint_specs
from openultrasast.model.taint import verdict
spec = taint_specs(language="c")["memory"]
assert "strcpy" in spec.bounded_sinks, "a CWE-121 sink is dischargeable by a bound"
governed = verdict(CpgResult(cpg_path=Path("c.bin"), run=lambda q, p: _bounded_rows(True)), spec, function="main")
ungoverned = verdict(CpgResult(cpg_path=Path("c.bin"), run=lambda q, p: _bounded_rows(False)), spec, function="main")
assert governed is not None and governed.rung is Rung.CORROBORATED
assert "governed by" in governed.witness, "the witness names the bound it found"
assert ungoverned is not None and ungoverned.rung is Rung.ENTAILED, "an unguarded copy is still the finding"
def test_only_a_length_sink_can_be_discharged_by_a_bound() -> None:
"""A `len > 10` check does not make an SQL injection safe. The CWE decides, and CWE-121 is C-only."""
from openultrasast.model.specs import taint_specs
assert not taint_specs(language="python")["injection"].bounded_sinks
assert not taint_specs(language="c")["injection"].bounded_sinks
assert taint_specs(language="c")["memory"].bounded_sinks