Files
Open_Ultra_SAST/src/openultrasast/semantic/facts.py
T
Marius CiepluchandClaude Opus 5.5 e13ce35a75 engine: admit TypeScript (jssrc2cpg, JavaScript taint specs via one alias), .mts/.cts, mixed JS/TS builds one CPG
Implemented with Codex. Not merged: wall-clock deadline tests failed at host
load 47; to be re-checked against main at normal load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 10:34:50 +02:00

440 lines
19 KiB
Python

"""Source, sink, and sanitizer facts as versioned data next to the ruleset.
An entry that knows a framework or a library carries ``framework = "<id>"`` or ``library = "<id>"`` (an id of
``ruleset/frameworks.toml``): a prior (learned-decision-engine Req 8.2). :func:`load_facts` keeps or drops the tagged
entries by ``priors`` -- ``"all"`` (the default: today's facts), ``"off"`` (language-level only) or a set of ids.
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
from ..ruleset.frameworks import Priors, kept, normalize_priors, read_tag, tag_of
DEFAULT_FACTS_DIR = Path(__file__).resolve().parents[1] / "ruleset" / "semantic"
TAINT_LANGUAGE_ALIAS = {"typescript": "javascript"}
_LANGUAGE_ALIASES = {
"c": "c",
"cpp": "c",
"python": "python",
"javascript": "javascript",
**TAINT_LANGUAGE_ALIAS,
"java": "java",
}
class FactLoadError(ValueError):
"""Raised when semantic fact files are missing or invalid."""
def __init__(self, message: str, reason: str = "facts_unavailable") -> None:
super().__init__(message)
self.reason = reason
@dataclass(frozen=True)
class SourceFact:
id: str
patterns: tuple[str, ...]
language: str
framework: str | None = None
library: str | None = None
@dataclass(frozen=True)
class SinkFact:
id: str
cwe: str
calls: tuple[str, ...]
rule_ids: tuple[str, ...]
language: str
weak_literals: tuple[str, ...] = ()
format_arg: int | None = None
# A DESTINATION sink (a redirect, an outbound request) is dangerous only where the value can choose where
# it goes. With `prefix_fixes_origin`, a value appended after a prefix that already fixes the origin -- a
# path `/x`, a `?query`, `scheme://host/`, or an `origin_anchors` call such as Django's `reverse()` -- is
# not an untrusted destination, and neither is one that only fills an anchor call's arguments.
prefix_fixes_origin: bool = False
origin_anchors: tuple[str, ...] = ()
framework: str | None = None
library: str | None = None
@dataclass(frozen=True)
class SanitizerFact:
id: str
calls: tuple[str, ...]
language: str
parameterized: bool = False
literal_format_arg: int | None = None
# An ESCAPE, not a cleansing: `esc_sql` backslashes quotes, which protects a value inside a quoted SQL
# literal and nothing else. `ORDER BY u.{$sortby}` is Ultimate Member's CVE-2024-1071, escaped and injectable.
quoted_only: bool = False
# A CHECK, not a transformation: `in_array($v, $allowed, true)`, `url_has_allowed_host_and_scheme(v)`. It
# makes a value safe only where it held -- inside the branch it guards, past an exit on its failure, or in
# the arm of a ternary it selects. Never a cleansing call on a path.
guard: bool = False
framework: str | None = None
library: str | None = None
@dataclass(frozen=True)
class DispatchFact:
"""A framework that connects a producer to a consumer through a STRING key rather than a call.
WordPress is the example that forced this, but nothing here is about WordPress. The shape is general --
a registry keyed by a name, written as a literal at both ends -- and it is how Django signals, jQuery
events, Symfony's event dispatcher and every plugin system of this kind route data. No CPG frontend can
draw the edge, because neither end names the other.
``register`` names the calls that put a callback INTO the registry, ``apply`` the calls that take a value
back OUT of it. Both belong in a fact table for the same reason sinks do: a query that hardcodes
`apply_filters` is a query that only works on one framework, and the next one needs a code change rather
than a row.
"""
id: str
register: tuple[str, ...]
apply: tuple[str, ...]
language: str
# What an apply call RETURNS. `returns` names the apply calls whose result is the dispatched value, and
# `value_arg` is that value's argument position (1-based, receiver excluded): WordPress's
# `apply_filters('hook', $value, ...$context)` returns the filtered `$value`, and its context arguments
# never become the result. An apply call not named in `returns` returns nothing a flow can carry
# (`do_action`). Without this the engine let EVERY argument reach the return: PMPro's random order code
# came out "tainted" because `$this` was passed as filter context. Empty keeps the engine's default.
returns: tuple[str, ...] = ()
value_arg: int = 0
# The `register` calls whose callbacks are handed REQUEST-BORNE arguments. Every other registration's
# callback receives what the matching apply call passes -- framework or plugin values such as the
# `WP_User` a profile hook gets -- so its parameters are not attacker input; where request data does
# reach an apply call's arguments, the hook half of the taint query follows it from there.
request_arguments: tuple[str, ...] = ()
framework: str | None = None
library: str | None = None
@dataclass(frozen=True)
class LayoutFact:
"""What a repository's PATHS say about scope, per ecosystem: which trees are somebody else's code and
which files are tests.
Both are policy, which is why they are rows and not constants. Vendored code is a separate unit --
"divide and rule": a library is analysed as a library, or not at all, never as part of the project
that bundles it -- so a `vendored` tree is out of the targets AND out of the graph. A `tests` path is
in scope but not product: its regions are ordered after the product's, never removed, because a test
that reaches a sink still names the sink.
Patterns are gitignore-style, matched against the repository-relative path and against each directory
on it: `vendor/` names any directory called vendor at any depth, `test-*.php` any file so named.
"""
id: str
vendored: tuple[str, ...]
tests: tuple[str, ...]
language: str
@dataclass(frozen=True)
class SemanticFacts:
version: str
sources: tuple[SourceFact, ...]
sinks: tuple[SinkFact, ...]
sanitizers: tuple[SanitizerFact, ...]
dispatches: tuple[DispatchFact, ...] = ()
layouts: tuple[LayoutFact, ...] = ()
def taint_language(self, language: str) -> str:
"""Prefer native facts when supplied; otherwise use the shared language aliases."""
if language in TAINT_LANGUAGE_ALIAS and any(
item.language == language
for items in (self.sources, self.sinks, self.sanitizers, self.dispatches, self.layouts)
for item in items
):
return language
return _LANGUAGE_ALIASES.get(language, language)
def for_language(self, language: str) -> SemanticFacts:
key = self.taint_language(language)
return SemanticFacts(
version=self.version,
sources=tuple(item for item in self.sources if item.language == key),
sinks=tuple(item for item in self.sinks if item.language == key),
sanitizers=tuple(item for item in self.sanitizers if item.language == key),
dispatches=tuple(item for item in self.dispatches if item.language == key),
layouts=tuple(item for item in self.layouts if item.language == key),
)
def _tags(item: dict[str, object], path: Path) -> dict[str, str | None]:
framework, library = read_tag(item, f"{path} {item.get('id')!r}")
return {"framework": framework, "library": library}
def with_priors(facts: SemanticFacts, priors: Priors) -> SemanticFacts:
"""``facts`` with the tagged entries ``priors`` does not keep removed (language-level entries always stay)."""
if priors == "all":
return facts
return SemanticFacts(
version=facts.version,
sources=tuple(f for f in facts.sources if kept(tag_of(f), priors)),
sinks=tuple(f for f in facts.sinks if kept(tag_of(f), priors)),
sanitizers=tuple(f for f in facts.sanitizers if kept(tag_of(f), priors)),
dispatches=tuple(f for f in facts.dispatches if kept(tag_of(f), priors)),
layouts=facts.layouts,
)
def _load_facts_uncached(directory: Path | None = None) -> SemanticFacts:
root = directory if directory is not None else DEFAULT_FACTS_DIR
if not root.is_dir():
raise FactLoadError(f"semantic fact directory missing: {root}")
sources: list[SourceFact] = []
sinks: list[SinkFact] = []
sanitizers: list[SanitizerFact] = []
dispatches: list[DispatchFact] = []
layouts: list[LayoutFact] = []
versions: list[str] = []
found = False
for path in sorted(root.glob("*.toml")):
found = True
try:
payload = tomllib.loads(path.read_text())
except (OSError, tomllib.TOMLDecodeError) as exc:
raise FactLoadError(f"invalid semantic fact file {path}: {exc}") from exc
language = str(payload.get("language") or path.stem)
versions.append(str(payload.get("version", "1")))
sources.extend(_sources(payload.get("source"), language, path))
sinks.extend(_sinks(payload.get("sink"), language, path))
sanitizers.extend(_sanitizers(payload.get("sanitizer"), language, path))
dispatches.extend(_dispatches(payload.get("dispatch"), language, path))
layouts.extend(_layouts(payload.get("layout"), language, path))
if not found:
raise FactLoadError(f"no semantic fact files in {root}")
return SemanticFacts(
version=versions[0] if versions else "1",
sources=tuple(sources),
sinks=tuple(sinks),
sanitizers=tuple(sanitizers),
dispatches=tuple(dispatches),
layouts=tuple(layouts),
)
def _dispatches(value: object, language: str, path: Path) -> list[DispatchFact]:
facts: list[DispatchFact] = []
for item in _items(value):
identifier = str(item.get("id", "")).strip()
if not identifier:
raise FactLoadError(f"dispatch without an id in {path}")
facts.append(
DispatchFact(
id=identifier,
register=_strings(item.get("register"), "register", path),
apply=_strings(item.get("apply"), "apply", path),
language=language,
returns=_returning(item, path),
value_arg=_value_arg(item, path),
request_arguments=_request_arguments(item, path),
**_tags(item, path),
)
)
return facts
def _returning(item: dict[str, object], path: Path) -> tuple[str, ...]:
returns = _strings(item.get("returns", []), "returns", path)
unknown = set(returns) - set(_strings(item.get("apply"), "apply", path))
if unknown:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: returns names calls that are not apply calls: {sorted(unknown)}")
return returns
def _request_arguments(item: dict[str, object], path: Path) -> tuple[str, ...]:
calls = _strings(item.get("request_arguments", []), "request_arguments", path)
unknown = set(calls) - set(_strings(item.get("register"), "register", path))
if unknown:
raise FactLoadError(
f"dispatch {item.get('id')!r} in {path}: request_arguments names calls that are not register calls: {sorted(unknown)}"
)
return calls
def _value_arg(item: dict[str, object], path: Path) -> int:
value = item.get("value_arg", 0)
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: value_arg must be a non-negative integer")
if item.get("returns") and value < 1:
raise FactLoadError(f"dispatch {item.get('id')!r} in {path}: returns needs the value's argument position")
return value
def _layouts(value: object, language: str, path: Path) -> list[LayoutFact]:
facts: list[LayoutFact] = []
for item in _items(value):
identifier = str(item.get("id", "")).strip()
if not identifier:
raise FactLoadError(f"layout without an id in {path}")
facts.append(
LayoutFact(
id=identifier,
vendored=_strings(item.get("vendored", []), "vendored", path),
tests=_strings(item.get("tests", []), "tests", path),
language=language,
)
)
return facts
def _items(value: object) -> list[dict[str, object]]:
if value is None:
return []
if not isinstance(value, list):
raise FactLoadError("fact tables must be arrays")
rows: list[dict[str, object]] = []
for item in value:
if not isinstance(item, dict):
raise FactLoadError("each fact row must be a table")
rows.append(item)
return rows
def _strings(value: object, field: str, path: Path) -> tuple[str, ...]:
if value is None:
return ()
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
raise FactLoadError(f"{path} has invalid {field} list")
return tuple(str(item) for item in value)
def _sources(value: object, language: str, path: Path) -> list[SourceFact]:
rows: list[SourceFact] = []
for item in _items(value):
ident = item.get("id")
if not isinstance(ident, str) or not ident:
raise FactLoadError(f"{path} source is missing id")
patterns = _strings(item.get("patterns"), "patterns", path)
if not patterns:
raise FactLoadError(f"{path} source {ident} has no patterns")
rows.append(SourceFact(id=ident, patterns=patterns, language=language, **_tags(item, path)))
return rows
def _flag(value: object, field: str, path: Path, ident: str) -> bool:
if value is None:
return False
if not isinstance(value, bool):
raise FactLoadError(f"{path} fact {ident} has a non-boolean {field}")
return value
def _sinks(value: object, language: str, path: Path) -> list[SinkFact]:
rows: list[SinkFact] = []
for item in _items(value):
ident = item.get("id")
cwe = item.get("cwe")
if not isinstance(ident, str) or not ident:
raise FactLoadError(f"{path} sink is missing id")
if not isinstance(cwe, str) or not cwe.startswith("CWE-"):
raise FactLoadError(f"{path} sink {ident} must bind a CWE id")
calls = _strings(item.get("calls"), "calls", path)
if not calls:
raise FactLoadError(f"{path} sink {ident} has no calls")
format_arg = item.get("format_arg")
if format_arg is not None and (not isinstance(format_arg, int) or isinstance(format_arg, bool)):
raise FactLoadError(f"{path} sink {ident} has invalid format_arg")
rows.append(
SinkFact(
id=ident,
cwe=cwe,
calls=calls,
rule_ids=_strings(item.get("rule_ids"), "rule_ids", path),
language=language,
weak_literals=_strings(item.get("weak_literals"), "weak_literals", path),
format_arg=format_arg if isinstance(format_arg, int) else None,
prefix_fixes_origin=_flag(item.get("prefix_fixes_origin"), "prefix_fixes_origin", path, ident),
origin_anchors=_strings(item.get("origin_anchors"), "origin_anchors", path),
**_tags(item, path),
)
)
return rows
def _sanitizers(value: object, language: str, path: Path) -> list[SanitizerFact]:
rows: list[SanitizerFact] = []
for item in _items(value):
ident = item.get("id")
if not isinstance(ident, str) or not ident:
raise FactLoadError(f"{path} sanitizer is missing id")
calls = _strings(item.get("calls"), "calls", path)
if not calls:
raise FactLoadError(f"{path} sanitizer {ident} has no calls")
literal = item.get("literal_format_arg")
if literal is not None and (not isinstance(literal, int) or isinstance(literal, bool)):
raise FactLoadError(f"{path} sanitizer {ident} has invalid literal_format_arg")
rows.append(
SanitizerFact(
id=ident,
calls=calls,
language=language,
parameterized=bool(item.get("parameterized", False)),
literal_format_arg=literal if isinstance(literal, int) else None,
quoted_only=_flag(item.get("quoted_only"), "quoted_only", path, ident),
guard=_flag(item.get("guard"), "guard", path, ident),
**_tags(item, path),
)
)
return rows
# Parsed once per ruleset STATE, not once per call. Region discovery asks for the family specs of every region,
# and each spec call loaded the facts again: 89,127 TOML parses and 275 s of a 339 s discovery on a WordPress
# plugin, paid twice per push. The key is every fact file's name, size and modification time, so an edited
# ruleset -- a test's fixture, a maintainer's change -- is read again, and the result is immutable, so sharing
# it is safe. A load that fails is not remembered.
_FACTS_MEMO: dict[tuple[object, ...], SemanticFacts] = {}
_LISTED: dict[tuple[str, str], tuple[int, tuple[Path, ...]]] = {}
def _ruleset_state(root: Path, pattern: str) -> tuple[object, ...]:
"""Every matching file's name, size and modification time -- cheaply, because every question asks.
The directory is listed again only when its own stamp moves (a file added, removed or renamed); an edit in
place moves the file's stamp, which is read every time. Globbing and resolving on every call was 7% of a
whole push with the engine stubbed out.
"""
stamp = root.stat().st_mtime_ns
listed = _LISTED.get((str(root), pattern))
if listed is None or listed[0] != stamp:
listed = (stamp, tuple(sorted(root.glob(pattern))))
_LISTED[(str(root), pattern)] = listed
return (str(root), stamp, *((path.name, *_stat_of(path)) for path in listed[1]))
def _stat_of(path: Path) -> tuple[int, int]:
info = path.stat()
return info.st_size, info.st_mtime_ns
def load_facts(directory: Path | None = None, *, priors: Priors = "all") -> SemanticFacts:
"""The facts of ``directory`` (default: the bundled ruleset), with the priors ``priors`` keeps: ``"all"`` (the
default, today's facts), ``"off"`` or a set of framework/library ids."""
chosen = normalize_priors(priors)
root = directory if directory is not None else DEFAULT_FACTS_DIR
if not root.is_dir():
return with_priors(_load_facts_uncached(directory), chosen)
key = _ruleset_state(root, "*.toml")
if key not in _FACTS_MEMO:
_FACTS_MEMO[key] = _load_facts_uncached(directory)
if chosen == "all":
return _FACTS_MEMO[key]
scoped = (*key, "priors", chosen if isinstance(chosen, str) else tuple(sorted(chosen)))
if scoped not in _FACTS_MEMO:
_FACTS_MEMO[scoped] = with_priors(_FACTS_MEMO[key], chosen)
return _FACTS_MEMO[scoped]