Files
Open_Ultra_SAST/src/openultrasast/ruleset/semantic/python.toml
T
Marius CiepluchandClaude Opus 5.5 03f2ce7162 feat(taint): guards -- a validator or allowlist check makes a value safe where it held
Three of the four fixed-side alerts left in the independent population had one
cause: the fix was a check, and checks were not modelled.
- wger's fix is `url_has_allowed_host_and_scheme`: `if not ...(next_url):
  next_url = reverse(..)` and `if next_url and ...(next_url): return redirect`.
- Ultimate Member's fix is an allowlist: `in_array(strtoupper($order), [...],
  true) ? $order : 'ASC'` and `elseif (in_array($sortby, $allowed, true))`.
(The fourth, Signal K, is `assertAllowedHost`, a project-local function. It is not
covered.)

Sanitizer facts can now say `guard = true`. A guard is a check, never a cleansing
call on a path. A flow is discharged only when a guard tests a variable the path
carries, and the flow is in one of these places:
- inside the branch whose condition requires the check. The guard may be combined
  with `&&` only;
- past a failure test, a single `!` combined with `||` only, whose branch exits
  (return, exit, die, wp_die, throw) or overwrites the value, and whose condition
  dominates the flow's end;
- through a ternary whose condition requires the check, where the other arm cannot
  carry the value. The engine can enter through the condition's own copy of the
  value, which never becomes the result.

Facts: `in_array` (PHP) and Django's `url_has_allowed_host_and_scheme` /
`is_safe_url` (Python). A row now consults guards even when a request lists no
sanitizers.

Two Joern-gated tests cover the discharged shapes and, as controls, an unguarded
redirect, a guard on a different variable, a failure that is only logged, the
unguarded else branch, and the ternary arm the check does not select. Both tests
fail without the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-09-25 16:56:15 +02:00

148 lines
4.6 KiB
TOML

version = "1"
language = "python"
[[source]]
id = "request"
patterns = [
"request.args",
"request.form",
"request.data",
"request.json",
"request.cookies",
"request.values",
"request.get_json",
"request.headers",
"request.files",
"request.get",
]
# Django and Django REST framework. The source vocabulary above is Flask's alone, so on a Django project no
# flow could begin anywhere: OpenCVE's notification test POSTs to a URL read from `form.cleaned_data`, and the
# scan answered the file and found nothing. `cleaned_data` is what a form bound to the request hands back --
# validated for type, not for trust.
[[source]]
id = "django-request"
patterns = [
"request.GET",
"request.POST",
"request.body",
"request.COOKIES",
"request.FILES",
"request.META",
"request.query_params",
"cleaned_data",
]
[[source]]
id = "argv"
patterns = ["sys.argv"]
[[source]]
id = "stdin"
patterns = ["sys.stdin", "stdin.read"]
[[source]]
id = "input"
patterns = ["input("]
[[sink]]
id = "eval"
cwe = "CWE-95"
calls = ["eval", "exec"]
rule_ids = ["python-unsafe-eval"]
[[sink]]
id = "pickle.loads"
cwe = "CWE-502"
calls = ["pickle.loads", "pickle.load", "yaml.load", "marshal.loads", "cPickle.loads"]
rule_ids = ["python-unsafe-deserialization"]
[[sink]]
id = "execute"
cwe = "CWE-89"
calls = ["execute", "executemany"]
rule_ids = ["python-sql-injection"]
[[sink]]
id = "os.system"
cwe = "CWE-78"
calls = ["os.system", "os.popen", "subprocess.run", "subprocess.call", "subprocess.Popen", "subprocess.check_output"]
rule_ids = ["python-os-command", "python-shell-true"]
[[sink]]
id = "hashlib"
cwe = "CWE-327"
calls = ["hashlib.md5", "hashlib.sha1", "hashlib.new"]
rule_ids = ["python-weak-hash"]
weak_literals = ["md5", "sha1", "MD5", "SHA1", "sha-1"]
[[sanitizer]]
id = "ast.literal_eval"
calls = ["ast.literal_eval"]
[[sanitizer]]
id = "yaml.safe_load"
calls = ["yaml.safe_load", "safe_load"]
[[sanitizer]]
id = "parameterized_execute"
calls = ["execute", "executemany"]
parameterized = true
# --- model-grounded-detection task 4.2 -------------------------------------------------------------------
# Sinks for the four families that previously had no fact routing to them by CWE, plus the ORM raw-SQL forms
# the Joern go/no-go measured as misses (11 of 23). Every entry below is a documented dangerous API of the
# named library, not an inferred pattern; the provenance is on each entry so a maintainer can review the data
# as data. `rule_ids` is left empty where no pattern rule exists: these facts feed the model layer, and a
# sink fact is not a detection rule.
[[sink]]
# 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"
cwe = "CWE-89"
calls = ["objects.raw", "raw", "RawSQL", "extra", "text", "from_statement"]
rule_ids = []
[[sink]]
# CWE-22. Filesystem entry points that accept a caller-supplied path: the stdlib openers plus Flask's
# 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"]
rule_ids = []
[[sink]]
# CWE-79. The documented "this string is trusted HTML" escapes: Jinja/Flask render_template_string and
# 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"
cwe = "CWE-79"
calls = ["render_template_string", "Markup", "mark_safe", "format_html", "HttpResponse"]
rule_ids = []
[[sink]]
# CWE-918 (SSRF) and CWE-601 (open redirect): outbound requests and redirects to a caller-supplied
# 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"]
rule_ids = []
prefix_fixes_origin = true
[[sink]]
id = "redirect"
cwe = "CWE-601"
calls = ["redirect", "HttpResponseRedirect", "url_for"]
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
origin_anchors = ["reverse", "reverse_lazy"]
# Django's documented redirect-target check (`is_safe_url` is its pre-3.0 name). wger's fix for its open
# redirect is `if not url_has_allowed_host_and_scheme(next_url, ...): next_url = reverse(...)`.
[[sanitizer]]
id = "url_allowlist"
calls = ["url_has_allowed_host_and_scheme", "is_safe_url"]
guard = true