mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d1896cb8e7
commit
af81bccc70
@@ -11,7 +11,7 @@ def test_layout_rows_load_and_match() -> None:
|
||||
from openultrasast.model.layout import is_test_path, is_vendored, layout_facts
|
||||
|
||||
rows = layout_facts("php")
|
||||
assert [r.id for r in rows] == ["php_composer_and_wordpress"]
|
||||
assert [r.id for r in rows] == ["php_layout"]
|
||||
assert is_vendored("includes/vendor/whichbrowser/parser/data/profiles.php", rows)
|
||||
assert is_vendored("vendor/autoload.php", rows)
|
||||
assert not is_vendored("classes/class.mail.php", rows)
|
||||
|
||||
@@ -31,7 +31,7 @@ def _benchmark(name: str, *, reported_only: bool): # type: ignore[no-untyped-de
|
||||
|
||||
def test_php_rules_load_compile_and_are_php_scoped() -> None:
|
||||
rules = _php_rules()
|
||||
assert len(rules) == 13
|
||||
assert len(rules) == 14, "13 measured rules; php-unserialize split into its language and WordPress parts (task 2)"
|
||||
for rule in rules.values():
|
||||
assert rule.languages == ("php",) and rule.status in {"enabled", "shadow"}
|
||||
re.compile(rule.pattern)
|
||||
@@ -52,6 +52,7 @@ def test_every_php_rule_sink_is_in_the_engines_vocabulary() -> None:
|
||||
def test_status_and_precision_are_the_committed_measurement() -> None:
|
||||
measured = json.loads(MEASUREMENT.read_text())["rules"]
|
||||
for rule_id, rule in _php_rules().items():
|
||||
rule_id = "php-unserialize" if rule_id == "php-maybe-unserialize" else rule_id # split from it, keeps its numbers
|
||||
assert rule.status == measured[rule_id]["status"], rule_id
|
||||
assert rule.precision_estimate == measured[rule_id]["precision_lower_bound"], rule_id
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from openultrasast.ruleset import (
|
||||
def test_default_ruleset_loads_as_data() -> None:
|
||||
rules = load_ruleset(DEFAULT_RULESET_DIR)
|
||||
php = [rule for rule in rules if rule.languages == ("php",)]
|
||||
assert len(rules) == 32 + len(php) and len(php) == 13
|
||||
assert len(rules) == 32 + len(php) and len(php) == 14 # php-unserialize split in two (decision-engine task 2)
|
||||
assert all(rule.status == "enabled" for rule in rules if rule not in php), "PHP rules carry their measured status"
|
||||
assert all(not hasattr(rule, "severity") for rule in rules) # severity is policy-governed, not rule-local
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user