Skip to content

Commit e38378d

Browse files
committed
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`).
1 parent 74211cc commit e38378d

9 files changed

Lines changed: 144 additions & 1 deletion

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* Added support for the `frozendict` and `sentinel` builtins introduced in Python 3.15, and data-flow modeling for `frozendict` construction.

‎python/ql/lib/semmle/python/dataflow/new/internal/Builtins.qll‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ module Builtins {
4242
or
4343
// Python 3 only
4444
result in [
45-
"ascii", "breakpoint", "bytes", "exec",
45+
"ascii", "breakpoint", "bytes", "exec", "frozendict", "sentinel",
4646
// Exceptions
4747
"BlockingIOError", "BrokenPipeError", "ChildProcessError", "ConnectionAbortedError",
4848
"ConnectionError", "ConnectionRefusedError", "ConnectionResetError", "FileExistsError",

‎python/ql/lib/semmle/python/frameworks/Stdlib.qll‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4222,6 +4222,21 @@ module StdlibPrivate {
42224222
}
42234223
}
42244224

4225+
/** A flow summary for `frozendict`. */
4226+
class FrozendictSummary extends SummarizedCallable::Range {
4227+
FrozendictSummary() { this = "builtins.frozendict" }
4228+
4229+
override DataFlow::CallCfgNode getACall() { result = API::builtin("frozendict").getACall() }
4230+
4231+
override DataFlow::ArgumentNode getACallback() {
4232+
result = API::builtin("frozendict").getAValueReachableFromSource()
4233+
}
4234+
4235+
override predicate propagatesFlow(string input, string output, boolean preservesValue) {
4236+
any(DictSummary s).propagatesFlow(input, output, preservesValue)
4237+
}
4238+
}
4239+
42254240
/** A flow summary for `list`. */
42264241
class ListSummary extends SummarizedCallable::Range {
42274242
ListSummary() { this = "builtins.list" }

‎python/ql/test/library-tests/ApiGraphs/py3/test.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,12 @@ def python2_style():
119119
# In Python 3, `__builtin__` has no special meaning.
120120
from __builtin__ import open # $ use=moduleImport("__builtin__").getMember("open")
121121
open("hello.txt") # $ use=moduleImport("__builtin__").getMember("open").getReturn()
122+
123+
def python315_builtins():
124+
frozendict(key="value") # $ use=moduleImport("builtins").getMember("frozendict").getReturn()
125+
sentinel("MISSING") # $ use=moduleImport("builtins").getMember("sentinel").getReturn()
126+
127+
freeze = frozendict # $ use=moduleImport("builtins").getMember("frozendict")
128+
make_sentinel = sentinel # $ use=moduleImport("builtins").getMember("sentinel")
129+
freeze(key="value") # $ use=moduleImport("builtins").getMember("frozendict").getReturn()
130+
make_sentinel("UNSET") # $ use=moduleImport("builtins").getMember("sentinel").getReturn()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
missingAnnotationOnSink
2+
testFailures
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import python
2+
import utils.test.dataflow.NormalDataflowTest
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
semmle-extractor-options: --lang=3 --max-import-depth=1
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
def from_keywords():
2+
frozen = frozendict(tainted=SOURCE, clean=NONSOURCE)
3+
SINK(frozen["tainted"]) # $ flow="SOURCE, l:-1 -> frozen['tainted']"
4+
SINK_F(frozen["clean"])
5+
6+
7+
def from_mapping():
8+
frozen = frozendict({"tainted": SOURCE, "clean": NONSOURCE}, extra=NONSOURCE)
9+
SINK(frozen["tainted"]) # $ flow="SOURCE, l:-1 -> frozen['tainted']"
10+
SINK_F(frozen["clean"])
11+
SINK_F(frozen["extra"])
12+
13+
14+
def from_pairs():
15+
frozen = frozendict([("key", SOURCE)])
16+
SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']"
17+
18+
19+
def from_frozendict():
20+
original = frozendict(tainted=SOURCE, clean=NONSOURCE)
21+
frozen = frozendict(original)
22+
SINK(frozen["tainted"]) # $ flow="SOURCE, l:-2 -> frozen['tainted']"
23+
SINK_F(frozen["clean"])
24+
25+
26+
def from_unpacked_keywords():
27+
frozen = frozendict(**{"tainted": SOURCE, "clean": NONSOURCE})
28+
SINK(frozen["tainted"]) # $ MISSING: flow="SOURCE, l:-1 -> frozen['tainted']"
29+
SINK_F(frozen["clean"])
30+
31+
# Keyword unpacking has the same limitation for dict.
32+
mutable = dict(**{"tainted": SOURCE, "clean": NONSOURCE})
33+
SINK(mutable["tainted"]) # $ MISSING: flow="SOURCE, l:-1 -> mutable['tainted']"
34+
SINK_F(mutable["clean"])
35+
36+
37+
def nested_contents():
38+
frozen = frozendict(nested={"key": SOURCE})
39+
SINK(frozen["nested"]["key"]) # $ flow="SOURCE, l:-1 -> frozen['nested']['key']"
40+
41+
42+
def qualified_constructor():
43+
import builtins
44+
frozen = builtins.frozendict(key=SOURCE)
45+
SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']"
46+
47+
48+
def imported_constructor():
49+
from builtins import frozendict as freeze
50+
frozen = freeze(key=SOURCE)
51+
SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']"
52+
53+
54+
def aliased_constructor():
55+
freeze = frozendict
56+
frozen = freeze(key=SOURCE)
57+
SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']"
58+
59+
60+
def constructor_callback():
61+
frozen = next(map(frozendict, [{"key": SOURCE}]))
62+
SINK(frozen["key"]) # $ flow="SOURCE, l:-1 -> frozen['key']"
63+
64+
65+
def mapping_methods():
66+
frozen = frozendict(tainted=SOURCE, clean=NONSOURCE)
67+
SINK(frozen.get("tainted")) # $ flow="SOURCE, l:-1 -> frozen.get(..)"
68+
SINK_F(frozen.get("clean"))
69+
SINK_F(frozen.get("missing"))
70+
71+
copied = frozen.copy()
72+
SINK(copied["tainted"]) # $ flow="SOURCE, l:-6 -> copied['tainted']"
73+
SINK_F(copied["clean"])
74+
75+
values = list(frozen.values())
76+
SINK(values[0]) # $ flow="SOURCE, l:-10 -> values[0]"
77+
78+
items = list(frozen.items())
79+
SINK(items[0][1]) # $ flow="SOURCE, l:-13 -> items[0][1]"
80+
SINK_F(items[0][0])
81+
82+
83+
def copied_contents():
84+
original = {"key": SOURCE}
85+
frozen = frozendict(original)
86+
original["key"] = NONSOURCE
87+
SINK(frozen["key"]) # $ flow="SOURCE, l:-3 -> frozen['key']"
88+
SINK_F(original["key"])
89+
90+
91+
def shadowed_constructor():
92+
def frozendict(**kwargs):
93+
return {"key": NONSOURCE}
94+
95+
frozen = frozendict(key=SOURCE)
96+
SINK_F(frozen["key"])
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Add taintlib to PATH so it can be imported during runtime without any hassle
2+
import sys; import os; sys.path.append(os.path.dirname(os.path.dirname((__file__))))
3+
from taintlib import TAINTED_DICT, TAINTED_STRING, ensure_tainted
4+
5+
6+
def test_frozendict():
7+
ensure_tainted(frozendict(TAINTED_DICT)) # $ tainted
8+
ensure_tainted(frozendict(TAINTED_DICT)["name"]) # $ tainted
9+
ensure_tainted(frozendict(name=TAINTED_STRING)["name"]) # $ tainted
10+
ensure_tainted(frozendict([("name", TAINTED_STRING)])["name"]) # $ tainted
11+
12+
13+
if sys.version_info >= (3, 15):
14+
test_frozendict()

0 commit comments

Comments
 (0)