mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-14 02:08:18 +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
@@ -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]]
|
||||
|
||||
@@ -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/"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user