Files
Open_Ultra_SAST/src/openultrasast/semantic/taint.py
T
Marius Ciepluch 1ae41eea19 feat(propose-adjudicate-prove): overlay adjudicates inventory before prove
Regex inventory stays the proposer. MAP attaches promote/demote/unadjudicated/coverage
from intra-file taint (tree-sitter when a grammar parses, Python ast as last resort).
Deep sandboxes promotions only, ordered by the existing heuristic ranker plus optional
OpenRouter mechanism recall. The SAST pair slice scores overlay outcomes; the stage-1
detection gate and local pair gate stay inventory-only.
2026-09-04 13:57:33 +02:00

286 lines
8.8 KiB
Python

"""Intra-file taint on FileIR. Incomplete flow is unadjudicated, never demote."""
from __future__ import annotations
from dataclasses import dataclass
from .facts import SemanticFacts, SinkFact, SourceFact
from .ir import CallSite, FileIR, FunctionIR, parse_file
TAINTED = "tainted"
CONSTANT = "constant"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class TaintPath:
path: str
source: str
sink: str
sink_line: int
names: tuple[str, ...]
sanitizer: str | None = None
cwe: str = ""
@dataclass(frozen=True)
class Demotion:
path: str
sink: str
sink_line: int
dominating_fact: str
sanitizers: tuple[str, ...] = ()
cwe: str = ""
@dataclass(frozen=True)
class UnadjudicatedFlow:
path: str
reason: str
engine: str = "none"
@dataclass(frozen=True)
class FileFlow:
path: str
language: str
engine: str
taint_paths: tuple[TaintPath, ...]
demotions: tuple[Demotion, ...]
incomplete: tuple[tuple[int, str], ...]
def analyze_file(path: str, text: str, language: str, facts: SemanticFacts) -> FileFlow | UnadjudicatedFlow:
ir = parse_file(path, text, language)
if not ir.parse_ok:
return UnadjudicatedFlow(path=path, reason=ir.reason or "parse_failed", engine=ir.engine)
return analyze_ir(ir, facts.for_language(language))
def analyze_ir(ir: FileIR, facts: SemanticFacts) -> FileFlow:
paths: list[TaintPath] = []
demotions: list[Demotion] = []
incomplete: list[tuple[int, str]] = []
for function in ir.functions:
_analyze_function(ir.path, function, facts, paths, demotions, incomplete)
return FileFlow(
path=ir.path,
language=ir.language,
engine=ir.engine,
taint_paths=tuple(paths),
demotions=tuple(demotions),
incomplete=tuple(incomplete),
)
def _analyze_function(
path: str,
function: FunctionIR,
facts: SemanticFacts,
paths: list[TaintPath],
demotions: list[Demotion],
incomplete: list[tuple[int, str]],
) -> None:
states: dict[str, str] = {param: TAINTED for param in function.params}
sources: dict[str, str] = {param: "parameter" for param in function.params}
for _ in range(8):
changed = False
for bind in function.binds:
new_state, source = _bind_state(bind, states, sources, facts)
if states.get(bind.name) != new_state or (source and sources.get(bind.name) != source):
states[bind.name] = new_state
if source:
sources[bind.name] = source
changed = True
if not changed:
break
for call in function.calls:
_analyze_call(path, call, states, sources, facts, paths, demotions, incomplete)
def _bind_state(bind: object, states: dict[str, str], sources: dict[str, str], facts: SemanticFacts) -> tuple[str, str | None]:
from .ir import Bind
assert isinstance(bind, Bind)
source = _source_in(bind.value_text, facts)
if source:
return TAINTED, source.id
if bind.is_constant and not bind.names:
return CONSTANT, None
inherited = [_name_state(name, states) for name in bind.names]
if TAINTED in inherited:
tainted_name = next((name for name in bind.names if states.get(name) == TAINTED), None)
return TAINTED, sources.get(tainted_name or "")
if inherited and all(item == CONSTANT for item in inherited):
return CONSTANT, None
if bind.call_name and not _known_callee(bind.call_name, facts):
return UNKNOWN, None
if not bind.names and not bind.is_constant:
return UNKNOWN, None
return UNKNOWN, None
def _analyze_call(
path: str,
call: CallSite,
states: dict[str, str],
sources: dict[str, str],
facts: SemanticFacts,
paths: list[TaintPath],
demotions: list[Demotion],
incomplete: list[tuple[int, str]],
) -> None:
sink = _sink_for(call.name, facts)
sanitizer = _sanitizer_for(call, facts)
arg_states = [_arg_state(call, index, states, facts) for index in range(len(call.arg_texts))]
if sink is None:
return
if sanitizer is not None:
demotions.append(
Demotion(
path=path,
sink=sink.id,
sink_line=call.line,
dominating_fact=sanitizer,
sanitizers=(sanitizer,),
cwe=sink.cwe,
)
)
return
if TAINTED in arg_states:
source_name = _source_label(call, states, sources, facts)
paths.append(
TaintPath(
path=path,
source=source_name,
sink=sink.id,
sink_line=call.line,
names=_tainted_names(call, states),
cwe=sink.cwe,
)
)
return
if arg_states and all(item == CONSTANT for item in arg_states):
demotions.append(
Demotion(
path=path,
sink=sink.id,
sink_line=call.line,
dominating_fact="constant",
cwe=sink.cwe,
)
)
return
if not call.arg_texts:
incomplete.append((call.line, "flow_incomplete"))
return
incomplete.append((call.line, "flow_incomplete"))
def _arg_state(call: CallSite, index: int, states: dict[str, str], facts: SemanticFacts) -> str:
text = call.arg_texts[index]
if _source_in(text, facts):
return TAINTED
if call.arg_is_constant[index]:
return CONSTANT
names = call.arg_names[index]
inherited = [_name_state(name, states) for name in names]
if TAINTED in inherited:
return TAINTED
if inherited and all(item == CONSTANT for item in inherited):
return CONSTANT
if not names:
return UNKNOWN
return UNKNOWN
def _source_label(call: CallSite, states: dict[str, str], sources: dict[str, str], facts: SemanticFacts) -> str:
for text in call.arg_texts:
found = _source_in(text, facts)
if found is not None:
return found.id
for names in call.arg_names:
for name in names:
if states.get(name) == TAINTED:
return sources.get(name, "parameter")
return "parameter"
def _tainted_names(call: CallSite, states: dict[str, str]) -> tuple[str, ...]:
names: list[str] = []
for group in call.arg_names:
for name in group:
if states.get(name) == TAINTED:
names.append(name)
return tuple(names)
def _name_state(name: str, states: dict[str, str]) -> str:
return states.get(name, UNKNOWN)
def _source_in(text: str, facts: SemanticFacts) -> SourceFact | None:
for source in facts.sources:
for pattern in source.patterns:
if pattern and pattern in text:
return source
return None
def _sink_for(name: str, facts: SemanticFacts) -> SinkFact | None:
for sink in facts.sinks:
for call in sink.calls:
if _call_matches(name, call):
return sink
return None
def _sanitizer_for(call: CallSite, facts: SemanticFacts) -> str | None:
for sanitizer in facts.sanitizers:
if not any(_call_matches(call.name, name) for name in sanitizer.calls):
continue
if sanitizer.parameterized and call.extra_arg_is_sequence:
return sanitizer.id
if sanitizer.literal_format_arg is not None:
index = sanitizer.literal_format_arg
if index < len(call.arg_is_constant) and call.arg_is_constant[index] and _looks_like_format(call.arg_texts[index]):
return sanitizer.id
if not sanitizer.parameterized and sanitizer.literal_format_arg is None:
return sanitizer.id
return None
def _looks_like_format(text: str) -> bool:
stripped = text.strip()
return len(stripped) >= 2 and stripped[0] in {"'", '"'} and stripped[-1] == stripped[0]
def _known_callee(name: str, facts: SemanticFacts) -> bool:
if _sink_for(name, facts) is not None:
return True
if _source_in(name, facts) is not None:
return True
return any(any(_call_matches(name, call) for call in sanitizer.calls) for sanitizer in facts.sanitizers)
def _call_matches(name: str, fact_call: str) -> bool:
if name == fact_call:
return True
if name.endswith("." + fact_call):
return True
if fact_call.endswith(name) and "." not in name:
# "execute" matches "db.execute" via endswith .execute above; bare execute matches execute.
return name == fact_call.split(".")[-1]
return False
def merge_joern_paths(existing: tuple[TaintPath, ...], extra: tuple[TaintPath, ...]) -> tuple[TaintPath, ...]:
seen = {(item.path, item.sink_line, item.sink) for item in existing}
merged = list(existing)
for item in extra:
key = (item.path, item.sink_line, item.sink)
if key not in seen:
merged.append(item)
seen.add(key)
return tuple(merged)