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:
Marius Ciepluch
2026-09-30 18:49:05 +02:00
co-authored by Claude Opus 5.5
parent d1896cb8e7
commit af81bccc70
17 changed files with 872 additions and 41 deletions
+34 -3
View File
@@ -3,7 +3,13 @@ language = "java"
[[source]]
id = "servlet"
patterns = ["getParameter", "getHeader", "getCookies", "readLine", "getQueryString"]
framework = "jakarta-servlet"
patterns = ["getParameter", "getHeader", "getCookies", "getQueryString"]
# A request body or a console read line by line: the reader, not the servlet API.
[[source]]
id = "servlet"
patterns = ["readLine"]
[[sink]]
id = "exec"
@@ -45,6 +51,7 @@ rule_ids = []
[[sink]]
# CWE-79. Servlet response writers: what reaches these reaches the browser unescaped.
id = "response.write"
framework = "jakarta-servlet"
cwe = "CWE-79"
calls = ["println", "print", "write", "getWriter", "setHeader", "addHeader"]
rule_ids = []
@@ -53,13 +60,37 @@ rule_ids = []
# CWE-918 / CWE-601: outbound requests and redirects to a caller-supplied destination.
id = "outbound.request"
cwe = "CWE-918"
calls = ["openConnection", "openStream", "sendRedirect", "getForObject", "exchange"]
calls = ["openConnection", "openStream"]
rule_ids = []
[[sink]]
id = "outbound.request"
framework = "jakarta-servlet"
cwe = "CWE-918"
calls = ["sendRedirect"]
rule_ids = []
[[sink]]
id = "outbound.request"
framework = "spring"
cwe = "CWE-918"
calls = ["getForObject", "exchange"]
rule_ids = []
[[sanitizer]]
# CWE-22 / CWE-79. The documented normalising and encoding APIs; ESAPI is what the OWASP suite itself uses.
id = "java.escape"
calls = ["getCanonicalPath", "normalize", "ESAPI.encoder", "encodeForHTML", "encodeForURL", "StringEscapeUtils.escapeHtml4", "URLEncoder.encode"]
calls = ["getCanonicalPath", "normalize", "URLEncoder.encode"]
[[sanitizer]]
id = "java.escape"
library = "esapi"
calls = ["ESAPI.encoder", "encodeForHTML", "encodeForURL"]
[[sanitizer]]
id = "java.escape"
library = "commons-text"
calls = ["StringEscapeUtils.escapeHtml4"]
[[sanitizer]]
id = "parameterized_statement"
@@ -3,6 +3,7 @@ language = "javascript"
[[source]]
id = "req"
framework = "express"
patterns = ["req.query", "req.body", "req.params", "req.cookies", "req.headers", "request.query"]
[[sink]]
@@ -56,14 +57,35 @@ patterns = ["req.url", "request.url", "req.rawHeaders", "url.parse", "parsedUrl"
# dynamic key. These are the documented merge helpers plus the assignment forms.
id = "prototype.merge"
cwe = "CWE-1321"
calls = ["merge", "mergeDeep", "extend", "defaultsDeep", "Object.assign", "set", "setWith"]
calls = ["Object.assign"]
rule_ids = []
[[sink]]
id = "prototype.merge"
library = "lodash"
cwe = "CWE-1321"
calls = ["merge", "mergeDeep", "extend", "defaultsDeep", "set", "setWith"]
rule_ids = []
[[sink]]
# CWE-79. The documented "trusted HTML" sinks in the DOM and the major frameworks.
id = "html.unescaped"
cwe = "CWE-79"
calls = ["innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "dangerouslySetInnerHTML", "v-html"]
calls = ["innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"]
rule_ids = []
[[sink]]
id = "html.unescaped"
framework = "react"
cwe = "CWE-79"
calls = ["dangerouslySetInnerHTML"]
rule_ids = []
[[sink]]
id = "html.unescaped"
framework = "vue"
cwe = "CWE-79"
calls = ["v-html"]
rule_ids = []
[[sink]]
@@ -79,14 +101,28 @@ rule_ids = []
# limit on this family rather than guessed at here.
id = "html.response"
cwe = "CWE-79"
calls = ["res.write", "res.end", "res.send", "response.write", "response.end", "response.send"]
calls = ["res.write", "res.end", "response.write", "response.end"]
rule_ids = []
[[sink]]
id = "html.response"
framework = "express"
cwe = "CWE-79"
calls = ["res.send", "response.send"]
rule_ids = []
[[sink]]
# CWE-22. Node filesystem entry points that accept a caller-supplied path.
id = "path.open"
cwe = "CWE-22"
calls = ["readFile", "readFileSync", "createReadStream", "sendFile", "unlink", "unlinkSync"]
calls = ["readFile", "readFileSync", "createReadStream", "unlink", "unlinkSync"]
rule_ids = []
[[sink]]
id = "path.open"
framework = "express"
cwe = "CWE-22"
calls = ["sendFile"]
rule_ids = []
[[sink]]
@@ -109,13 +145,6 @@ rule_ids = []
id = "outbound.request"
cwe = "CWE-918"
calls = [
"axios", # axios, and axios.get/.post/.put/.patch/.delete/.head/.request through it
"got", # got
"needle", # needle
"request", # request / request-promise
"superagent", # superagent
"unirest", # unirest
"undici", # undici, and undici.fetch/.request through it
"fetch", # global fetch, node-fetch, cross-fetch
"http.get",
"http.request",
@@ -125,10 +154,34 @@ calls = [
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "outbound.request"
library = "node-http-clients"
cwe = "CWE-918"
calls = [
"axios", # axios, and axios.get/.post/.put/.patch/.delete/.head/.request through it
"got", # got
"needle", # needle
"request", # request / request-promise
"superagent", # superagent
"unirest", # unirest
"undici", # undici, and undici.fetch/.request through it
]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "redirect"
framework = "express"
cwe = "CWE-601"
calls = ["redirect"]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "redirect"
cwe = "CWE-601"
calls = ["redirect", "location.assign", "location.replace"]
calls = ["location.assign", "location.replace"]
rule_ids = []
prefix_fixes_origin = true
@@ -146,7 +199,22 @@ calls = ["path.normalize", "path.resolve"]
[[sanitizer]]
# CWE-79. The standard encoders: the DOM/global URI encoders and the common escaping helpers.
id = "html.escape"
calls = ["encodeURIComponent", "encodeURI", "escapeHtml", "escape", "sanitizeHtml", "DOMPurify.sanitize"]
calls = ["encodeURIComponent", "encodeURI", "escape"]
[[sanitizer]]
id = "html.escape"
library = "escape-html"
calls = ["escapeHtml"]
[[sanitizer]]
id = "html.escape"
library = "sanitize-html"
calls = ["sanitizeHtml"]
[[sanitizer]]
id = "html.escape"
library = "dompurify"
calls = ["DOMPurify.sanitize"]
[[layout]]
+27 -5
View File
@@ -46,6 +46,7 @@ patterns = ["php://input"]
# them itself, so they are not injection sinks.
[[sink]]
id = "wpdb"
framework = "wordpress"
cwe = "CWE-89"
calls = [
"$wpdb->get_var",
@@ -124,6 +125,12 @@ rule_ids = []
# --- deserialization ---------------------------------------------------------------------------------
[[sink]]
id = "unserialize"
cwe = "CWE-502"
calls = ["unserialize"]
rule_ids = []
[[sink]]
# `maybe_unserialize` is WordPress's own wrapper and the form every plugin in this project's corpus actually
# writes: across four plugins and 1,164 PHP files there is exactly one deserialization site, and it is spelled
@@ -134,8 +141,9 @@ rule_ids = []
# It is a real entry point rather than a safe wrapper: `maybe_unserialize` calls `unserialize` whenever the
# string looks serialized, and only returns the value untouched when it does not.
id = "unserialize"
framework = "wordpress"
cwe = "CWE-502"
calls = ["unserialize", "maybe_unserialize"]
calls = ["maybe_unserialize"]
rule_ids = []
# --- untrusted destination ---------------------------------------------------------------------------
@@ -167,7 +175,14 @@ calls = ["htmlspecialchars", "htmlentities", "html_entity_decode_safe"]
# a cleanser.
[[sanitizer]]
id = "sql_escape"
calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string", "esc_sql"]
calls = ["mysqli_real_escape_string", "mysql_real_escape_string", "pg_escape_string"]
quoted_only = true
# WordPress's escape of the same kind.
[[sanitizer]]
id = "sql_escape"
framework = "wordpress"
calls = ["esc_sql"]
quoted_only = true
[[sanitizer]]
@@ -193,6 +208,7 @@ guard = true
[[sanitizer]]
id = "wpdb_prepare"
framework = "wordpress"
calls = ["$wpdb->prepare"]
[[sanitizer]]
@@ -205,7 +221,12 @@ calls = ["basename", "realpath"]
[[sanitizer]]
id = "coerce"
calls = ["intval", "floatval", "filter_var", "settype", "absint"]
calls = ["intval", "floatval", "filter_var", "settype"]
[[sanitizer]]
id = "coerce"
framework = "wordpress"
calls = ["absint"]
# A prepared statement is a SHAPE, not a cleansing call: `mysqli_prepare` makes the query safe by binding
# rather than by transforming the value, which is the same distinction `execute`/`executemany` carry in
@@ -224,6 +245,7 @@ parameterized = true
# a row here rather than a change to the query. That is the whole reason it is a fact and not a constant.
[[dispatch]]
id = "wordpress_hooks"
framework = "wordpress"
register = ["add_action", "add_filter", "add_shortcode"]
apply = ["apply_filters", "apply_filters_ref_array", "do_action", "do_action_ref_array"]
# A filter returns its VALUE argument, filtered; its further arguments are context and never the result, and
@@ -240,8 +262,8 @@ request_arguments = ["add_shortcode"]
# (a bundled library is a separate unit); `tests` paths stay in scope but are not product, so their
# regions are ordered after the product's. Measured before this row: 207 of WP Statistics's 357 PHP files
# were vendored and two of them were 60% of its graph; 14 of the 67 regions ahead of MW WP Form's CVE
# were test-mail.php and test-mail-parser.php.
# were test-mail.php and test-mail-parser.php. A product filter (vendored and test paths), not detection: untagged.
[[layout]]
id = "php_composer_and_wordpress"
id = "php_layout"
vendored = ["vendor/", "node_modules/", "third_party/", "third-party/", "lib/vendor/"]
tests = ["tests/", "test/", "spec/", "test-*.php", "*Test.php", "*.test.php", "*_test.php", "phpunit/"]
+73 -6
View File
@@ -3,6 +3,7 @@ language = "python"
[[source]]
id = "request"
framework = "flask"
patterns = [
"request.args",
"request.form",
@@ -22,6 +23,7 @@ patterns = [
# validated for type, not for trust.
[[source]]
id = "django-request"
framework = "django"
patterns = [
"request.GET",
"request.POST",
@@ -54,9 +56,16 @@ rule_ids = ["python-unsafe-eval"]
[[sink]]
id = "pickle.loads"
cwe = "CWE-502"
calls = ["pickle.loads", "pickle.load", "yaml.load", "marshal.loads", "cPickle.loads"]
calls = ["pickle.loads", "pickle.load", "marshal.loads", "cPickle.loads"]
rule_ids = ["python-unsafe-deserialization"]
[[sink]]
id = "pickle.loads"
library = "pyyaml"
cwe = "CWE-502"
calls = ["yaml.load"]
rule_ids = []
[[sink]]
id = "execute"
cwe = "CWE-89"
@@ -82,6 +91,7 @@ calls = ["ast.literal_eval"]
[[sanitizer]]
id = "yaml.safe_load"
library = "pyyaml"
calls = ["yaml.safe_load", "safe_load"]
[[sanitizer]]
@@ -100,8 +110,16 @@ parameterized = true
# CWE-89. Django's documented raw-SQL escape hatches (docs: "Performing raw SQL queries") and the
# SQLAlchemy/peewee text forms. These were the single largest miss class in the go/no-go.
id = "orm.raw"
framework = "django"
cwe = "CWE-89"
calls = ["objects.raw", "raw", "RawSQL", "extra", "text", "from_statement"]
calls = ["objects.raw", "raw", "RawSQL", "extra"]
rule_ids = []
[[sink]]
id = "orm.raw"
library = "sqlalchemy"
cwe = "CWE-89"
calls = ["text", "from_statement"]
rule_ids = []
[[sink]]
@@ -109,7 +127,21 @@ rule_ids = []
# send_file/send_from_directory and Django's FileResponse.
id = "path.open"
cwe = "CWE-22"
calls = ["open", "send_file", "send_from_directory", "FileResponse", "os.remove", "os.unlink", "shutil.copy", "shutil.move", "shutil.rmtree"]
calls = ["open", "os.remove", "os.unlink", "shutil.copy", "shutil.move", "shutil.rmtree"]
rule_ids = []
[[sink]]
id = "path.open"
framework = "flask"
cwe = "CWE-22"
calls = ["send_file", "send_from_directory"]
rule_ids = []
[[sink]]
id = "path.open"
framework = "django"
cwe = "CWE-22"
calls = ["FileResponse"]
rule_ids = []
[[sink]]
@@ -117,8 +149,16 @@ rule_ids = []
# Markup, Django's mark_safe and format_html. Rendering a template *file* is not a sink; rendering a
# caller-supplied *string* is.
id = "html.unescaped"
framework = "flask"
cwe = "CWE-79"
calls = ["render_template_string", "Markup", "mark_safe", "format_html", "HttpResponse"]
calls = ["render_template_string", "Markup"]
rule_ids = []
[[sink]]
id = "html.unescaped"
framework = "django"
cwe = "CWE-79"
calls = ["mark_safe", "format_html", "HttpResponse"]
rule_ids = []
[[sink]]
@@ -126,14 +166,40 @@ rule_ids = []
# destination. requests/urllib/httpx are the documented HTTP clients; redirect is Flask's and Django's.
id = "outbound.request"
cwe = "CWE-918"
calls = ["requests.get", "requests.post", "requests.request", "urlopen", "urlretrieve", "httpx.get", "httpx.post"]
calls = ["urlopen", "urlretrieve"]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "outbound.request"
library = "requests"
cwe = "CWE-918"
calls = ["requests.get", "requests.post", "requests.request"]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "outbound.request"
library = "httpx"
cwe = "CWE-918"
calls = ["httpx.get", "httpx.post"]
rule_ids = []
prefix_fixes_origin = true
# `redirect` is Flask's (Django's shortcut shares the name; the Flask row carries it).
[[sink]]
id = "redirect"
framework = "flask"
cwe = "CWE-601"
calls = ["redirect", "url_for"]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "redirect"
framework = "django"
cwe = "CWE-601"
calls = ["redirect", "HttpResponseRedirect", "url_for"]
calls = ["HttpResponseRedirect"]
rule_ids = []
# Django's `reverse` builds an in-site path from a route NAME; request data can only fill its path segments.
prefix_fixes_origin = true
@@ -143,5 +209,6 @@ origin_anchors = ["reverse", "reverse_lazy"]
# redirect is `if not url_has_allowed_host_and_scheme(next_url, ...): next_url = reverse(...)`.
[[sanitizer]]
id = "url_allowlist"
framework = "django"
calls = ["url_has_allowed_host_and_scheme", "is_safe_url"]
guard = true