From e38378d7b304c94024b53f5c4ea64757cffc726e Mon Sep 17 00:00:00 2001 From: Taus Date: Tue, 22 Sep 2026 14:15:56 +0000 Subject: [PATCH] Python: Model new built-ins For `sentinel`, we just add it to the list of the known built-ins. For `frozendict`, we additionally add a flow summary (which just replicates the existing summary for `dict`). --- .../2026-09-22-python315-builtins.md | 4 + .../python/dataflow/new/internal/Builtins.qll | 2 +- .../lib/semmle/python/frameworks/Stdlib.qll | 15 +++ .../test/library-tests/ApiGraphs/py3/test.py | 9 ++ .../frozendict/NormalDataflowTest.expected | 2 + .../dataflow/frozendict/NormalDataflowTest.ql | 2 + .../library-tests/dataflow/frozendict/options | 1 + .../library-tests/dataflow/frozendict/test.py | 96 +++++++++++++++++++ .../test_frozendict.py | 14 +++ 9 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 python/ql/lib/change-notes/2026-09-22-python315-builtins.md create mode 100644 python/ql/test/library-tests/dataflow/frozendict/NormalDataflowTest.expected create mode 100644 python/ql/test/library-tests/dataflow/frozendict/NormalDataflowTest.ql create mode 100644 python/ql/test/library-tests/dataflow/frozendict/options create mode 100644 python/ql/test/library-tests/dataflow/frozendict/test.py create mode 100644 python/ql/test/library-tests/dataflow/tainttracking/defaultAdditionalTaintStep-py3/test_frozendict.py diff --git a/python/ql/lib/change-notes/2026-09-22-python315-builtins.md b/python/ql/lib/change-notes/2026-09-22-python315-builtins.md new file mode 100644 index 000000000000..56c4f34be927 --- /dev/null +++ b/python/ql/lib/change-notes/2026-09-22-python315-builtins.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* Added support for the `frozendict` and `sentinel` builtins introduced in Python 3.15, and data-flow modeling for `frozendict` construction. diff --git a/python/ql/lib/semmle/python/dataflow/new/internal/Builtins.qll b/python/ql/lib/semmle/python/dataflow/new/internal/Builtins.qll index 764af5d9dc57..4d3477e85a6b 100644 --- a/python/ql/lib/semmle/python/dataflow/new/internal/Builtins.qll +++ b/python/ql/lib/semmle/python/dataflow/new/internal/Builtins.qll @@ -42,7 +42,7 @@ module Builtins { or // Python 3 only result in [ - "ascii", "breakpoint", "bytes", "exec", + "ascii", "breakpoint", "bytes", "exec", "frozendict", "sentinel", // Exceptions "BlockingIOError", "BrokenPipeError", "ChildProcessError", "ConnectionAbortedError", "ConnectionError", "ConnectionRefusedError", "ConnectionResetError", "FileExistsError", diff --git a/python/ql/lib/semmle/python/frameworks/Stdlib.qll b/python/ql/lib/semmle/python/frameworks/Stdlib.qll index c02aa4bb6d8a..ab38584be6bd 100644 --- a/python/ql/lib/semmle/python/frameworks/Stdlib.qll +++ b/python/ql/lib/semmle/python/frameworks/Stdlib.qll @@ -4222,6 +4222,21 @@ module StdlibPrivate { } } + /** A flow summary for `frozendict`. */ + class FrozendictSummary extends SummarizedCallable::Range { + FrozendictSummary() { this = "builtins.frozendict" } + + override DataFlow::CallCfgNode getACall() { result = API::builtin("frozendict").getACall() } + + override DataFlow::ArgumentNode getACallback() { + result = API::builtin("frozendict").getAValueReachableFromSource() + } + + override predicate propagatesFlow(string input, string output, boolean preservesValue) { + any(DictSummary s).propagatesFlow(input, output, preservesValue) + } + } + /** A flow summary for `list`. */ class ListSummary extends SummarizedCallable::Range { ListSummary() { this = "builtins.list" } diff --git a/python/ql/test/library-tests/ApiGraphs/py3/test.py b/python/ql/test/library-tests/ApiGraphs/py3/test.py index 4c48e1f1daee..64e7c2a0a9c7 100644 --- a/python/ql/test/library-tests/ApiGraphs/py3/test.py +++ b/python/ql/test/library-tests/ApiGraphs/py3/test.py @@ -119,3 +119,12 @@ def python2_style(): # In Python 3, `__builtin__` has no special meaning. from __builtin__ import open # $ use=moduleImport("__builtin__").getMember("open") open("hello.txt") # $ use=moduleImport("__builtin__").getMember("open").getReturn() + +def python315_builtins(): + frozendict(key="value") # $ use=moduleImport("builtins").getMember("frozendict").getReturn() + sentinel("MISSING") # $ use=moduleImport("builtins").getMember("sentinel").getReturn() + + freeze = frozendict # $ use=moduleImport("builtins").getMember("frozendict") + make_sentinel = sentinel # $ use=moduleImport("builtins").getMember("sentinel") + freeze(key="value") # $ use=moduleImport("builtins").getMember("frozendict").getReturn() + make_sentinel("UNSET") # $ use=moduleImport("builtins").getMember("sentinel").getReturn() diff --git a/python/ql/test/library-tests/dataflow/frozendict/NormalDataflowTest.expected b/python/ql/test/library-tests/dataflow/frozendict/NormalDataflowTest.expected new file mode 100644 index 000000000000..2fad7bb9a843 --- /dev/null +++ b/python/ql/test/library-tests/dataflow/frozendict/NormalDataflowTest.expected @@ -0,0 +1,2 @@ +missingAnnotationOnSink +testFailures diff --git a/python/ql/test/library-tests/dataflow/frozendict/NormalDataflowTest.ql b/python/ql/test/library-tests/dataflow/frozendict/NormalDataflowTest.ql new file mode 100644 index 000000000000..1e0627bfcca1 --- /dev/null +++ b/python/ql/test/library-tests/dataflow/frozendict/NormalDataflowTest.ql @@ -0,0 +1,2 @@ +import python +import utils.test.dataflow.NormalDataflowTest diff --git a/python/ql/test/library-tests/dataflow/frozendict/options b/python/ql/test/library-tests/dataflow/frozendict/options new file mode 100644 index 000000000000..89369a90996b --- /dev/null +++ b/python/ql/test/library-tests/dataflow/frozendict/options @@ -0,0 +1 @@ +semmle-extractor-options: --lang=3 --max-import-depth=1 diff --git a/python/ql/test/library-tests/dataflow/frozendict/test.py b/python/ql/test/library-tests/dataflow/frozendict/test.py new file mode 100644 index 000000000000..05346736e240 --- /dev/null +++ b/python/ql/test/library-tests/dataflow/frozendict/test.py @@ -0,0 +1,96 @@ +def from_keywords(): + frozen = frozendict(tainted=SOURCE, clean=NONSOURCE) + SINK(frozen["tainted"]) # $ flow="SOURCE, l:-1 -> frozen['tainted']" + SINK_F(frozen["clean"]) + + +def from_mapping(): + frozen = frozendict({"tainted": SOURCE, "clean": NONSOURCE}, extra=NONSOURCE) + SINK(frozen["tainted"]) # $ flow="SOURCE, l:-1 -> frozen['tainted']" + SINK_F(frozen["clean"]) + SINK_F(frozen["extra"]) + + +def from_pairs(): + frozen = frozendict([("key", SOURCE)]) + SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']" + + +def from_frozendict(): + original = frozendict(tainted=SOURCE, clean=NONSOURCE) + frozen = frozendict(original) + SINK(frozen["tainted"]) # $ flow="SOURCE, l:-2 -> frozen['tainted']" + SINK_F(frozen["clean"]) + + +def from_unpacked_keywords(): + frozen = frozendict(**{"tainted": SOURCE, "clean": NONSOURCE}) + SINK(frozen["tainted"]) # $ MISSING: flow="SOURCE, l:-1 -> frozen['tainted']" + SINK_F(frozen["clean"]) + + # Keyword unpacking has the same limitation for dict. + mutable = dict(**{"tainted": SOURCE, "clean": NONSOURCE}) + SINK(mutable["tainted"]) # $ MISSING: flow="SOURCE, l:-1 -> mutable['tainted']" + SINK_F(mutable["clean"]) + + +def nested_contents(): + frozen = frozendict(nested={"key": SOURCE}) + SINK(frozen["nested"]["key"]) # $ flow="SOURCE, l:-1 -> frozen['nested']['key']" + + +def qualified_constructor(): + import builtins + frozen = builtins.frozendict(key=SOURCE) + SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']" + + +def imported_constructor(): + from builtins import frozendict as freeze + frozen = freeze(key=SOURCE) + SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']" + + +def aliased_constructor(): + freeze = frozendict + frozen = freeze(key=SOURCE) + SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']" + + +def constructor_callback(): + frozen = next(map(frozendict, [{"key": SOURCE}])) + SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']" + + +def mapping_methods(): + frozen = frozendict(tainted=SOURCE, clean=NONSOURCE) + SINK(frozen.get("tainted")) # $ flow="SOURCE, l:-1 -> frozen.get(..)" + SINK_F(frozen.get("clean")) + SINK_F(frozen.get("missing")) + + copied = frozen.copy() + SINK(copied["tainted"]) # $ flow="SOURCE, l:-6 -> copied['tainted']" + SINK_F(copied["clean"]) + + values = list(frozen.values()) + SINK(values[0]) # $ flow="SOURCE, l:-10 -> values[0]" + + items = list(frozen.items()) + SINK(items[0][1]) # $ flow="SOURCE, l:-13 -> items[0][1]" + SINK_F(items[0][0]) + + +def copied_contents(): + original = {"key": SOURCE} + frozen = frozendict(original) + original["key"] = NONSOURCE + SINK(frozen["key"]) # $ flow="SOURCE, l:-3 -> frozen['key']" + SINK_F(original["key"]) + + +def shadowed_constructor(): + def frozendict(**kwargs): + return {"key": NONSOURCE} + + frozen = frozendict(key=SOURCE) + SINK_F(frozen["key"]) diff --git a/python/ql/test/library-tests/dataflow/tainttracking/defaultAdditionalTaintStep-py3/test_frozendict.py b/python/ql/test/library-tests/dataflow/tainttracking/defaultAdditionalTaintStep-py3/test_frozendict.py new file mode 100644 index 000000000000..64d1a63a695f --- /dev/null +++ b/python/ql/test/library-tests/dataflow/tainttracking/defaultAdditionalTaintStep-py3/test_frozendict.py @@ -0,0 +1,14 @@ +# Add taintlib to PATH so it can be imported during runtime without any hassle +import sys; import os; sys.path.append(os.path.dirname(os.path.dirname((__file__)))) +from taintlib import TAINTED_DICT, TAINTED_STRING, ensure_tainted + + +def test_frozendict(): + ensure_tainted(frozendict(TAINTED_DICT)) # $ tainted + ensure_tainted(frozendict(TAINTED_DICT)["name"]) # $ tainted + ensure_tainted(frozendict(name=TAINTED_STRING)["name"]) # $ tainted + ensure_tainted(frozendict([("name", TAINTED_STRING)])["name"]) # $ tainted + + +if sys.version_info >= (3, 15): + test_frozendict()