Files
Open_Ultra_SAST/tests/test_semantic_priors.py
Marius CiepluchandClaude Opus 5.5 af81bccc70 feat(ruleset): tag framework-specific knowledge as optional priors (decision-engine task 2)
ruleset/frameworks.toml lists 19 framework and library ids (wordpress, flask, django, express,
spring, jakarta-servlet, react, vue and 11 libraries), each with its languages, dependency
package names and a symbol lexicon used only by the lint and for stratification.

Every framework/library entry of section 2 is tagged `framework`/`library`, and mixed entries
are split so a tag covers a whole entry (split pieces keep the entry's id and flags, in place):
semantic facts 38 tagged entries (wordpress 6, django 6, flask 4, express 4, jakarta-servlet 3,
pyyaml 2, one each for spring, sqlalchemy, requests, httpx, lodash, node-http-clients,
escape-html, sanitize-html, dompurify, esapi, commons-text, react, vue); the dispatch fact
behind the hook-as-source path is wordpress; the PHP layout row is renamed php_layout and stays
untagged (a product filter). Quick rules: 9 tagged (wordpress 6, flask, pyyaml,
node-http-clients); php-unserialize is split into php-unserialize and php-maybe-unserialize;
the lint also caught WordPress escapers in exclusions and axios/yaml.load in mixed rules, so
those keep their ids as tagged rules and gain a `fallback_for` language-level rule (6) that is
active only when the prior is off.

load_facts and load_ruleset take priors ("all" by default -- today's scan --, "off", or a set
of ids). tests/test_semantic_priors.py: the lint (no untagged entry contains a lexicon symbol
of its language), "off" removes exactly the tagged entries and activates the fallbacks, split
entries keep their language-level calls. Rule and layout counts in three existing tests follow the split and the rename.

Unchanged defaults: test_benchmark_output_is_byte_identical_to_golden_baseline passes, and the
quick findings of all 15 benchmark manifests (full StaticFinding records) are byte-identical
before and after. priors=off on the same manifests: 61 -> 57 findings; the 4 lost are js-ssrf
(axios), php-wpdb-sql-composition, python-ssti and python yaml.load; 5 PHP and 1 Python finding
move to their fallback rule at the same location. Semantic calls with priors off: php 75/92,
python 32/77, javascript 42/70, java 24/41, c 27/27 (the engine-side effect needs Joern and was
not measured).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-09-30 18:49:05 +02:00

167 lines
9.2 KiB
Python

"""Framework knowledge as tagged priors (learned-decision-engine task 2; Req 8.2, design section 2).
The lint: no untagged entry of ``ruleset/semantic/*.toml`` or of a quick ``rules.toml`` contains a symbol of
``ruleset/frameworks.toml`` (a symbol of a framework or library of the entry's language, matched as a whole token;
a quick rule's pattern is read with its regex escapes removed). ``priors="off"`` removes exactly the tagged entries
and activates the language-level fallbacks of mixed quick rules; the default ``"all"`` is today's ruleset.
"""
from __future__ import annotations
import re
import tomllib
from pathlib import Path
import pytest
from openultrasast.ruleset import DEFAULT_RULESET_DIR, load_ruleset
from openultrasast.ruleset.frameworks import FrameworksError, framework_ids, load_frameworks, symbol_in
from openultrasast.semantic.facts import DEFAULT_FACTS_DIR, load_facts
TABLES = ("source", "sink", "sanitizer", "dispatch")
LANGUAGE_FAMILY = {"typescript": "javascript", "cpp": "c"}
def _lexicon(language: str) -> dict[str, tuple[str, ...]]:
wanted = LANGUAGE_FAMILY.get(language, language)
return {row.id: row.symbols for row in load_frameworks() if wanted in {LANGUAGE_FAMILY.get(lang, lang) for lang in row.languages}}
def _texts(item: dict[str, object]) -> list[str]:
keys = ("patterns", "calls", "register", "apply", "returns", "request_arguments", "origin_anchors")
return [str(text) for key in keys for text in item.get(key, []) or []] # type: ignore[union-attr]
def _semantic_entries() -> list[tuple[str, str, dict[str, object]]]:
rows = []
for path in sorted(DEFAULT_FACTS_DIR.glob("*.toml")):
data = tomllib.loads(path.read_text())
rows += [
(str(data.get("language") or path.stem), f"{path.name} {table} {item['id']}", item)
for table in TABLES
for item in data.get(table, [])
]
return rows
def _rules() -> list[dict[str, object]]:
return [rule for path in sorted(DEFAULT_RULESET_DIR.glob("*/rules.toml")) for rule in tomllib.loads(path.read_text()).get("rule", [])]
def _unescaped(pattern: str) -> str:
return re.sub(r"\\(.)", r"\1", pattern)
def test_no_untagged_entry_names_a_framework_symbol() -> None:
offenders: list[str] = []
for language, where, item in _semantic_entries():
if item.get("framework") or item.get("library"):
continue
for framework, symbols in _lexicon(language).items():
hits = sorted({s for s in symbols for text in _texts(item) if symbol_in(s, text)})
if hits:
offenders.append(f"{where}: {framework} {hits}")
for rule in _rules():
if rule.get("framework") or rule.get("library"):
continue
text = _unescaped(str(rule["pattern"]))
for language in rule.get("languages") or []: # type: ignore[union-attr]
for framework, symbols in _lexicon(str(language)).items():
hits = sorted(s for s in symbols if symbol_in(s, text))
if hits:
offenders.append(f"rule {rule['rule_id']}: {framework} {hits}")
assert not offenders, "untagged framework knowledge (tag it, or split the entry):\n" + "\n".join(sorted(set(offenders)))
def test_the_lint_catches_an_untagged_framework_entry() -> None:
item = {"id": "x", "calls": ["$wpdb->get_var", "mysqli_query"]}
assert any(symbol_in(s, t) for s in _lexicon("php")["wordpress"] for t in _texts(item))
assert not any(symbol_in(s, "maybe_unserialized_value") for s in ("maybe_unserialize",)), "whole tokens only"
assert symbol_in("wpdb->", _unescaped(r"\$(?!wpdb->)[A-Za-z_]")), "a symbol inside an exclusion still counts"
def test_every_tag_names_a_listed_id_and_the_lexicon_is_well_formed() -> None:
ids = framework_ids()
tags = [item.get("framework") or item.get("library") for _, _, item in _semantic_entries()]
tags += [rule.get("framework") or rule.get("library") for rule in _rules()]
assert {t for t in tags if t} <= ids
assert {"wordpress", "flask", "django", "express", "spring", "jakarta-servlet"} <= ids
for row in load_frameworks():
assert row.symbols and row.languages and row.kind in ("framework", "library"), row.id
def _signature(facts: object) -> set[tuple[str, str, str, tuple[str, ...]]]:
rows = set()
for kind in ("sources", "sinks", "sanitizers", "dispatches"):
for fact in getattr(facts, kind):
texts = tuple(getattr(fact, "patterns", ()) or getattr(fact, "calls", ()) or getattr(fact, "register", ()))
rows.add((kind, fact.language, fact.id, texts))
return rows
def test_priors_off_removes_exactly_the_tagged_entries() -> None:
every, off = load_facts(), load_facts(priors="off")
assert load_facts(priors="all") is every, "the default is today's facts, memoised"
tagged = {
row for row in _signature(every)
for fact in getattr(every, row[0]) if (fact.language, fact.id) == row[1:3]
and tuple(getattr(fact, "patterns", ()) or getattr(fact, "calls", ()) or getattr(fact, "register", ())) == row[3]
and (fact.framework or fact.library)
} # fmt: skip
assert tagged, "the semantic facts carry tagged priors"
assert _signature(every) - _signature(off) == tagged and _signature(off) <= _signature(every)
assert off.layouts == every.layouts, "layouts are product filters, not detection"
rules_all = {r.rule_id: r for r in load_ruleset(DEFAULT_RULESET_DIR)}
rules_off = {r.rule_id: r for r in load_ruleset(DEFAULT_RULESET_DIR, priors="off")}
tagged_rules = {rid for rid, r in rules_all.items() if r.framework or r.library}
fallbacks = {rid for rid, r in rules_off.items() if r.fallback_for}
assert tagged_rules and set(rules_all) - set(rules_off) == tagged_rules
assert set(rules_off) - set(rules_all) == fallbacks and {rules_off[f].fallback_for for f in fallbacks} <= tagged_rules
assert not any(r.fallback_for for r in rules_all.values()), "a fallback is inactive while its prior is on"
def test_split_entries_keep_their_language_level_calls() -> None:
off = load_facts(priors="off")
def calls(language: str, kind: str, ident: str) -> set[str]:
scoped = off.for_language(language)
return {c for f in getattr(scoped, kind) if f.id == ident for c in (getattr(f, "calls", ()) or getattr(f, "patterns", ()))}
assert calls("php", "sinks", "unserialize") == {"unserialize"}
assert calls("php", "sanitizers", "sql_escape") == {"mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string"}
assert calls("php", "sanitizers", "coerce") == {"intval", "floatval", "filter_var", "settype"}
assert calls("php", "sinks", "wpdb") == set() and not off.for_language("php").dispatches
assert calls("python", "sinks", "pickle.loads") == {"pickle.loads", "pickle.load", "marshal.loads", "cPickle.loads"}
assert calls("python", "sinks", "path.open") == {"open", "os.remove", "os.unlink", "shutil.copy", "shutil.move", "shutil.rmtree"}
assert calls("python", "sinks", "outbound.request") == {"urlopen", "urlretrieve"} and calls("python", "sources", "request") == set()
assert calls("javascript", "sinks", "outbound.request") == {"fetch", "http.get", "http.request", "https.get", "https.request"}
assert calls("javascript", "sinks", "prototype.merge") == {"Object.assign"}
assert calls("javascript", "sanitizers", "html.escape") == {"encodeURIComponent", "encodeURI", "escape"}
assert calls("java", "sinks", "outbound.request") == {"openConnection", "openStream"}
assert calls("java", "sanitizers", "java.escape") == {"getCanonicalPath", "normalize", "URLEncoder.encode"}
assert calls("java", "sources", "servlet") == {"readLine"}
rules = {r.rule_id: r for r in load_ruleset(DEFAULT_RULESET_DIR, priors="off")}
assert "php-unserialize" in rules and "php-maybe-unserialize" not in rules
assert re.search(rules["php-unserialize"].pattern, "$x = unserialize($_POST['a']);")
assert not re.search(rules["php-unserialize"].pattern, "$x = maybe_unserialize($_POST['a']);")
assert re.search(rules["python-unsafe-deserialization-language"].pattern, "pickle.loads(data)")
assert not re.search(rules["python-unsafe-deserialization-language"].pattern, "yaml.load(data)")
def test_a_chosen_prior_keeps_only_its_own_entries() -> None:
wordpress = load_facts(priors=frozenset({"wordpress"}))
assert {c for f in wordpress.for_language("php").sinks if f.id == "wpdb" for c in f.calls} >= {"$wpdb->get_var"}
assert not any(f.framework == "flask" for f in wordpress.sources)
rules = {r.rule_id for r in load_ruleset(DEFAULT_RULESET_DIR, priors=frozenset({"wordpress"}))}
assert "php-wpdb-sql-composition" in rules and "python-ssti" not in rules and "python-unsafe-deserialization-language" in rules
with pytest.raises(FrameworksError, match="unknown priors"):
load_facts(priors=frozenset({"no-such-framework"}))
with pytest.raises(FrameworksError):
load_ruleset(DEFAULT_RULESET_DIR, priors="none") # type: ignore[arg-type]
def test_a_tag_outside_frameworks_toml_is_refused(tmp_path: Path) -> None:
(tmp_path / "x.toml").write_text('language = "php"\n[[sink]]\nid = "s"\nframework = "nope"\ncwe = "CWE-89"\ncalls = ["q"]\n')
with pytest.raises(ValueError, match="not a framework id"):
load_facts(tmp_path)