learn: in-function def-use slice from the tree-sitter layer + leak audit (\$0)

slice.py renders source -> step -> sink lines with fixed wording for flow,
no-flow and not-analysable; the prompt option is default-off (byte-identical
default prompt). semantic IR: JS/C declarations count as assignments, augmented
assignment keeps its target (gate, map_gate, pair_gate outputs unchanged).
Audit over exp-004's 501 units: 59 flow, 161 no-flow, 281 not analysable;
has-flow within-pair accuracy 0.52, pooled AUC 0.55 -- no leak. Fixes a
numbering bug that rejected every excerpt with a blank line. With Codex.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-03 14:56:52 +02:00
co-authored by Claude Opus 5.5
parent 2836b78819
commit 1b2c7993a7
7 changed files with 6789 additions and 4 deletions
+1
View File
@@ -204,6 +204,7 @@ class ProgramSpec:
priors: str = "off"
inputs: str = DEFAULT_INPUTS # the input profile: which instruments the classifier never sees (v1: the engine)
max_output_tokens: int = MAX_OUTPUT_TOKENS
slice: bool = False # reserved experiment switch; rendering stays disabled until the leak audit is reviewed
sampling: str = SAMPLING[0] # temperature | retrieval_ensemble (an experiment arm's setting; never compiled)
+155
View File
@@ -0,0 +1,155 @@
"""Bounded, local def-use evidence, never a vulnerability verdict.
Uses the shipped FileIR and fact/taxonomy routing. Parameters are potential inputs,
as in semantic.taint; unknown call returns carry their arguments conservatively.
Sanitizers stay on paths (they do not erase connectivity). Facts currently scope
sanitizers by language, not family, so a sanitizer is not a discharge claim.
The flat IR has no branch edges or dominance: no guard is asserted, and branch
assignments are visited in textual order. No interprocedural or alias analysis.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from textwrap import dedent
from typing import Literal
from ..model.specs import taint_specs
from ..semantic.facts import load_facts
from ..semantic.ir import Bind, CallSite, FunctionIR, parse_file
from ..semantic.taint import _call_matches, _sanitizer_for, _source_in
from .excerpt import redact
Role = Literal["source", "step", "sanitizer", "guard", "sink"]
# The renderer uses two spaces; normalization strips the suffix on blank lines.
_NUMBERED = re.compile(r"^\s*(\d+)(?: (.*))?$")
@dataclass(frozen=True, order=True)
class Step:
line: int
role: Role
code: str
@dataclass(frozen=True)
class Slice:
steps: tuple[Step, ...] = ()
def function_slice(excerpt_text: str, language: str, family: str) -> Slice | None:
"""Return local paths, an empty slice, or None for an unsupported/unparseable excerpt.
Numbered excerpts retain their displayed numbers; raw code uses 1-based lines.
Diffs are never parsed. A truncated/malformed excerpt cannot establish no-flow.
Nested functions are analysed separately and never share definitions.
"""
raw = excerpt_text.split("\ndiff:\n", 1)[0].splitlines()
matches = [_NUMBERED.match(line) for line in raw]
if any(matches) and not all(matches):
return None
numbers = [int(m[1]) if m else i + 1 for i, m in enumerate(matches)]
lines = [(m[2] or "") if m else line for m, line in zip(matches, raw, strict=True)]
if len(set(numbers)) != len(numbers):
return None
code = dedent("\n".join(lines))
ir = parse_file("excerpt", code, language)
if not ir.parse_ok:
return None
spec = taint_specs(language=language).get(family)
if spec is None:
return Slice()
shown = redact(lines, language)
facts = load_facts().for_language(language)
def step(line: int, role: Role) -> Step:
return Step(numbers[line - 1], role, shown[line - 1].strip())
def paths(function: FunctionIR) -> set[Step]:
states = {name: {step(function.start_line, "source")} for name in function.params}
found: set[Step] = set()
def value(text: str, names: tuple[str, ...], constant: bool, line: int, end: int) -> set[Step]:
if constant:
return set()
result = set().union(*(states.get(name, set()) for name in names))
if _source_in(text, facts):
result.add(step(line, "source"))
if result:
# Keep only sanitizers whose call is inside this expression, not
# an unrelated call elsewhere on the same source line.
for call in function.calls:
if (
call.line >= line
and (not end or call.order <= end)
and any(
re.search(r"(?<![\w$])" + re.escape(name) + r"\s*\(", text) and _call_matches(call.name, name)
for name in spec.sanitizers
)
):
result.add(step(call.line, "sanitizer"))
return result
events: list[Bind | CallSite] = [*function.binds, *function.calls]
# Tree-sitter supplies evaluation ends, including same-line statements.
# The AST fallback supplies equivalent end-line/column positions.
events.sort(key=lambda e: (e.order if e.order else e.line, isinstance(e, Bind)))
for event in events:
if isinstance(event, Bind):
inherited = value(event.value_text, event.names, event.is_constant, event.line, event.order)
states[event.name] = inherited | {step(event.line, "step")} if inherited else set()
continue
if not any(_call_matches(event.name, name) for name in spec.sinks):
continue
reached: set[Step] = set()
for text, names, constant in zip(event.arg_texts, event.arg_names, event.arg_is_constant, strict=True):
reached.update(value(text, names, constant, event.line, event.order))
if reached:
if _sanitizer_for(event, facts):
reached.add(step(event.line, "sanitizer"))
found.update(reached | {step(event.line, "sink")})
return found
result: set[Step] = set()
for function in ir.functions:
result.update(paths(function))
order = {"source": 0, "step": 1, "sanitizer": 2, "guard": 3, "sink": 4}
return Slice(tuple(sorted(result, key=lambda s: (s.line, order[s.role]))))
def render_slice(slice_or_none: Slice | None, reason: str) -> str:
"""Fixed framing; reason is the language, never free-form provenance text."""
prefix = "Data flow inside the function:"
if slice_or_none is None:
language = (
reason
if reason
in {
"python",
"javascript",
"typescript",
"tsx",
"c",
"cpp",
"java",
"php",
"go",
"ruby",
"rust",
"csharp",
"perl",
"kotlin",
"swift",
"scala",
"lua",
"groovy",
"shell",
"other",
}
else "other"
)
return f"{prefix} not analysed ({language} is not supported)."
if not slice_or_none.steps:
return f"{prefix} no source reaches a sink of this family."
return prefix + "\n" + "\n".join(f"L{s.line} {s.role}: {s.code}" for s in slice_or_none.steps)
+13 -4
View File
@@ -17,7 +17,7 @@ _KIND = {
},
"javascript": {
"function": frozenset({"function_declaration", "method_definition", "arrow_function", "function_expression"}),
"assign": frozenset({"assignment_expression", "augmented_assignment_expression"}),
"assign": frozenset({"assignment_expression", "augmented_assignment_expression", "variable_declarator"}),
"call": frozenset({"call_expression"}),
"args": frozenset({"arguments"}),
"string": frozenset({"string"}),
@@ -25,7 +25,7 @@ _KIND = {
},
"c": {
"function": frozenset({"function_definition"}),
"assign": frozenset({"assignment_expression"}),
"assign": frozenset({"assignment_expression", "init_declarator"}),
"call": frozenset({"call_expression"}),
"args": frozenset({"argument_list"}),
"string": frozenset({"string_literal"}),
@@ -152,12 +152,17 @@ def _bind(node: Any, kinds: dict[str, frozenset[str]]) -> Bind | None:
if not name:
return None
value_text = _text(value_node) if value_node is not None else ""
augmented = node.type in {"augmented_assignment", "augmented_assignment_expression"}
names = _identifiers(value_node) if value_node is not None else ()
if augmented:
names = (name, *names)
return Bind(
name=name,
order=int(node.end_byte),
line=_line(node),
value_text=value_text,
is_constant=_is_constant(value_node, kinds) if value_node is not None else False,
names=_identifiers(value_node) if value_node is not None else (),
is_constant=not augmented and _is_constant(value_node, kinds) if value_node is not None else False,
names=names,
call_name=_call_name(value_node, kinds) if value_node is not None and value_node.type in kinds["call"] else None,
)
@@ -167,6 +172,7 @@ def _call(node: Any, kinds: dict[str, frozenset[str]]) -> CallSite:
arg_nodes = list(getattr(args_node, "named_children", ()) or ()) if args_node is not None else []
extra = len(arg_nodes) >= 2 and arg_nodes[1].type in _SEQUENCE
return CallSite(
order=int(node.end_byte),
name=_callee_name(node, args_node),
line=_line(node),
arg_texts=tuple(_text(arg) for arg in arg_nodes),
@@ -211,6 +217,9 @@ def _function_name(node: Any) -> str:
def _params(node: Any, kinds: dict[str, frozenset[str]]) -> tuple[str, ...]:
parameter = node.child_by_field_name("parameter")
if parameter is not None:
return _identifiers(parameter)
for child in _walk(node, skip=frozenset()):
if child.type in kinds["params"]:
return _identifiers(child)
+10
View File
@@ -15,6 +15,7 @@ class Bind:
is_constant: bool
names: tuple[str, ...]
call_name: str | None
order: int = 0 # evaluation end position; calls precede their containing assignment
@dataclass(frozen=True)
@@ -25,6 +26,7 @@ class CallSite:
arg_is_constant: tuple[bool, ...]
arg_names: tuple[tuple[str, ...], ...]
extra_arg_is_sequence: bool
order: int = 0
@dataclass(frozen=True)
@@ -119,6 +121,7 @@ def _collect_stmt(stmt: ast.stmt, binds: list[Bind], calls: list[CallSite]) -> N
is_constant=value_bind[1],
names=value_bind[2],
call_name=value_bind[3],
order=_end_position(stmt.value),
)
)
_collect_expr_calls(stmt.value, calls)
@@ -133,6 +136,7 @@ def _collect_stmt(stmt: ast.stmt, binds: list[Bind], calls: list[CallSite]) -> N
is_constant=value_bind[1],
names=value_bind[2],
call_name=value_bind[3],
order=_end_position(stmt.value),
)
)
_collect_expr_calls(stmt.value, calls)
@@ -147,6 +151,7 @@ def _collect_stmt(stmt: ast.stmt, binds: list[Bind], calls: list[CallSite]) -> N
is_constant=False,
names=tuple(sorted(set(value_bind[2]) | {stmt.target.id})),
call_name=value_bind[3],
order=_end_position(stmt.value),
)
)
_collect_expr_calls(stmt.value, calls)
@@ -190,6 +195,7 @@ def _call_site(node: ast.Call) -> CallSite:
if len(node.args) >= 2:
extra_sequence = isinstance(node.args[1], ast.Tuple | ast.List | ast.Set)
return CallSite(
order=_end_position(node),
name=_call_name(node),
line=node.lineno,
arg_texts=tuple(arg_texts),
@@ -199,6 +205,10 @@ def _call_site(node: ast.Call) -> CallSite:
)
def _end_position(node: ast.AST) -> int:
return int(getattr(node, "end_lineno", 0) or 0) * 1_000_000 + int(getattr(node, "end_col_offset", 0) or 0)
def _call_name(node: ast.AST) -> str:
func = node.func if isinstance(node, ast.Call) else node
return _unparse(func)