Files
Open_Ultra_SAST/src/openultrasast/semantic/facts.py
T

231 lines
8.3 KiB
Python
Raw Normal View History

"""Source, sink, and sanitizer facts as versioned data next to the ruleset."""
from __future__ import annotations
import tomllib
from dataclasses import dataclass
from pathlib import Path
DEFAULT_FACTS_DIR = Path(__file__).resolve().parents[1] / "ruleset" / "semantic"
_LANGUAGE_ALIASES = {
"c": "c",
"cpp": "c",
"python": "python",
"javascript": "javascript",
"typescript": "javascript",
"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
@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
@dataclass(frozen=True)
class SanitizerFact:
id: str
calls: tuple[str, ...]
language: str
parameterized: bool = False
literal_format_arg: int | 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
@dataclass(frozen=True)
class SemanticFacts:
version: str
sources: tuple[SourceFact, ...]
sinks: tuple[SinkFact, ...]
sanitizers: tuple[SanitizerFact, ...]
dispatches: tuple[DispatchFact, ...] = ()
def for_language(self, language: str) -> SemanticFacts:
key = _LANGUAGE_ALIASES.get(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),
)
def load_facts(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] = []
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))
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),
)
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,
)
)
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))
return rows
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,
)
)
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,
)
)
return rows