Files

167 lines
9.2 KiB
Python
Raw Permalink Normal View History

"""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)