fix(model-grounded-detection): two modelling bugs the first live Joern run exposed

Both were found by running the real engine, and neither was visible offline.

1. A sink was its own sanitizer. The shipped facts mark some sanitizers with a
   *shape* qualifier -- parameterized ("execute(sql, params) binds rather than
   interpolates") and literal_format_arg ("printf with a constant format string
   is safe"). These describe a safe FORM OF THE SINK CALL, not a call that
   cleans a value on the path. Flattening them into the sanitizer list made
   `execute` both the injection sink and its own sanitizer, so every SQL flow
   would have reported as already-clean and nothing could ever be entailed.
   Fixed as a class, not an instance: any fact carrying a shape qualifier is
   excluded from `sanitizers` and kept in `safe_shape_sinks` for the shape test
   task 4.2 will model properly. Fixing only the Python `parameterized` case
   would have left C's entire printf family -- which the generalised test then
   caught immediately.

2. labeled_family lost its deterministic derivation in group 1. The deleted
   classifier resolved a pair's family by declared label, else obligation, else
   mechanism, else CWE, and only asked an LLM when every link failed -- which
   for a labeled corpus was never. Group 1 reduced this to the declared label
   alone, which silently emptied every family whose pairs are labeled by CWE:
   the injection slice went to zero pairs. The chain is restored, still with no
   model call, and the family census again matches the committed entailment
   ceiling exactly (injection 50, memory 176, access_control 8, ...).

Also: the Joern seam now runs in the CPG's own scratch directory, because
`joern --script` writes a `workspace/` beside the working directory and would
otherwise leave one inside the repository being analysed.

The taint query itself was corrected against the live engine: the sink is the
call's ARGUMENTS (data flows into arguments, not into the call node) and
reachableByFlows is called on the sink with the source. With that, the
three-hop chain our flat IR could not resolve is found:
  request.args["cmd"] -> cmd -> "echo " + cmd -> full -> os.system(full)

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:45:42 +02:00
co-authored by Claude Opus 5
parent 2b973dc0db
commit f59e0f5b5a
5 changed files with 132 additions and 48 deletions
+34 -11
View File
@@ -18,7 +18,10 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from .taxonomy import FamilyTaxonomy
CandidateKind = Literal["call", "bind", "operation", "config"]
@@ -255,7 +258,7 @@ class CandidateReport:
}
def ceiling(cases: Sequence[object], *, taxonomy: object, generator: str = "ir") -> CandidateReport:
def ceiling(cases: Sequence[object], *, taxonomy: FamilyTaxonomy, generator: str = "ir") -> CandidateReport:
"""Per family and per slice, the fraction of labeled functions a generator can put a candidate inside.
``generator`` is ``ir`` or ``ruleset``. The ruleset arm exists so the comparison that rejected it — 12.1%
@@ -269,7 +272,7 @@ def ceiling(cases: Sequence[object], *, taxonomy: object, generator: str = "ir")
gaps: list[tuple[str, str, str]] = []
sites: dict[str, list[int]] = {}
for case in cases:
family = labeled_family(case)
family = labeled_family(case, taxonomy)
slice_name = str(getattr(case, "slice", "") or "")
region_path = str(getattr(case, "relpath", "") or "")
with tempfile.TemporaryDirectory(prefix="ousast-ceiling-") as scratch:
@@ -329,17 +332,37 @@ def _distribution(counts: list[int]) -> dict[str, int]:
}
def labeled_family(case: object) -> str:
"""The family a pair is labeled with, or ``unknown``.
def labeled_family(case: object, taxonomy: FamilyTaxonomy | None = None) -> str:
"""The family a pair belongs to, derived deterministically: declared label, else obligation, else mechanism,
else CWE. ``unknown`` when none of those resolve.
The deleted ``learning/rounds.py`` fell back to the LLM classifier here. Nothing does now: an unlabeled
pair is honestly ``unknown`` rather than guessed, which is the same discipline the evidence ladder applies
to a claim the model cannot arbitrate.
The deleted ``learning/classify.py`` had exactly this chain and only asked an LLM when every link failed —
which, for a labeled corpus, was never. Group 1 first reduced this to the declared label alone, which
silently emptied every family whose pairs are labeled by CWE rather than by name; the chain is restored
here because it was never the noisy part.
"""
from .taxonomy import load_families
families: FamilyTaxonomy = taxonomy if taxonomy is not None else load_families()
declared: list[str] = []
derived: list[str] = []
for row in getattr(case, "expected", ()) or ():
family = getattr(row, "family", None)
if family:
return str(family)
label = getattr(row, "family", None)
if label and any(family.id == label for family in families.families):
declared.append(str(label))
continue
if getattr(row, "obligation", None):
derived.append("access_control")
mechanism = getattr(row, "mechanism", None)
by_mechanism = families.family_of_mechanism(str(mechanism)) if mechanism else None
if by_mechanism is not None and by_mechanism.id != "unknown":
derived.append(by_mechanism.id)
continue
by_cwe = families.family_of_cwe(str(getattr(row, "cwe", "") or ""))
if by_cwe is not None and by_cwe.id != "unknown":
derived.append(by_cwe.id)
for family in declared or derived:
return family
return "unknown"
+30 -3
View File
@@ -76,13 +76,28 @@ def classify_guard_text(text: str) -> str:
@dataclass(frozen=True)
class TaintSpec:
"""What a flow family's source→sink−sanitizer query matches on, for one language."""
"""What a flow family's source-to-sink-minus-sanitizer query matches on, for one language.
``sanitizers`` holds only *cleansing calls* -- a node on the flow path that makes the value safe, such as
``ast.literal_eval``. It deliberately excludes every fact that carries a **shape qualifier**, because
those describe a safe *form of the sink call*, not a call that cleans a value:
parameterized ``execute(sql, params)`` binds rather than interpolates
literal_format_arg ``printf("literal", x)`` has a constant format string
Flattening either kind into the sanitizer list is actively wrong: the sink becomes its own sanitizer, so
every flow through it reports as already-clean and nothing is ever entailed. Both were present in the
shipped facts (`execute` for Python, the whole printf family for C) and both surfaced on the first live
Joern run. The names are kept in ``safe_shape_sinks`` so the shape test can be modelled properly in task
4.2 rather than silently mis-modelled here.
"""
family: str
language: str
sources: tuple[str, ...]
sinks: tuple[str, ...]
sanitizers: tuple[str, ...]
safe_shape_sinks: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -113,7 +128,12 @@ def taint_specs(
scoped = (facts if facts is not None else load_facts()).for_language(language)
families = taxonomy if taxonomy is not None else load_families()
sources = tuple(sorted({pattern for fact in scoped.sources for pattern in fact.patterns}))
sanitizers = tuple(sorted({call for fact in scoped.sanitizers for call in fact.calls}))
# A shape qualifier means "this sink is safe in this form", never "this call cleans the value".
def _is_shape(fact: object) -> bool:
return bool(getattr(fact, "parameterized", False)) or getattr(fact, "literal_format_arg", None) is not None
sanitizers = tuple(sorted({call for fact in scoped.sanitizers if not _is_shape(fact) for call in fact.calls}))
safe_shapes = tuple(sorted({call for fact in scoped.sanitizers if _is_shape(fact) for call in fact.calls}))
by_family: dict[str, set[str]] = {}
for sink in scoped.sinks:
family = families.family_of_cwe(sink.cwe)
@@ -121,7 +141,14 @@ def taint_specs(
continue
by_family.setdefault(family.id, set()).update(sink.calls)
return {
family_id: TaintSpec(family=family_id, language=language, sources=sources, sinks=tuple(sorted(calls)), sanitizers=sanitizers)
family_id: TaintSpec(
family=family_id,
language=language,
sources=sources,
sinks=tuple(sorted(calls)),
sanitizers=sanitizers,
safe_shape_sinks=safe_shapes,
)
for family_id, calls in sorted(by_family.items())
}