From 41b9b2ad928c0336c2944444b4158f01a404aa0b Mon Sep 17 00:00:00 2001 From: kazuki-netizen <245197254+kazuki-netizen@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:51:30 +0900 Subject: [PATCH] Python: model MongoDB collection reads as database sources --- .../2026-10-13-pymongo-database-sources.md | 4 ++ .../lib/semmle/python/frameworks/PyMongo.qll | 9 +++ .../frameworks/pymongo/ConceptsTest.expected | 0 .../frameworks/pymongo/ConceptsTest.ql | 2 + .../pymongo/DefaultSources.expected | 0 .../frameworks/pymongo/DefaultSources.ql | 6 ++ .../library-tests/frameworks/pymongo/reads.py | 39 +++++++++++ .../SqlInjection.expected | 64 ++++++++++++++++++ .../SqlInjection.ext.yml | 6 ++ .../SqlInjection.qlref | 1 + .../pymongo_reads.py | 67 +++++++++++++++++++ 11 files changed, 198 insertions(+) create mode 100644 python/ql/lib/change-notes/2026-10-13-pymongo-database-sources.md create mode 100644 python/ql/test/library-tests/frameworks/pymongo/ConceptsTest.expected create mode 100644 python/ql/test/library-tests/frameworks/pymongo/ConceptsTest.ql create mode 100644 python/ql/test/library-tests/frameworks/pymongo/DefaultSources.expected create mode 100644 python/ql/test/library-tests/frameworks/pymongo/DefaultSources.ql create mode 100644 python/ql/test/library-tests/frameworks/pymongo/reads.py create mode 100644 python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.expected create mode 100644 python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.ext.yml create mode 100644 python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.qlref create mode 100644 python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/pymongo_reads.py diff --git a/python/ql/lib/change-notes/2026-10-13-pymongo-database-sources.md b/python/ql/lib/change-notes/2026-10-13-pymongo-database-sources.md new file mode 100644 index 000000000000..4e8b25c90afe --- /dev/null +++ b/python/ql/lib/change-notes/2026-10-13-pymongo-database-sources.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* MongoDB collection reads modeled by the `pymongo` and `flask_pymongo` libraries are now sources under the `database` threat model. This allows queries such as `py/sql-injection` to detect second-order injection through stored documents when the `database` or `local` threat model is enabled. diff --git a/python/ql/lib/semmle/python/frameworks/PyMongo.qll b/python/ql/lib/semmle/python/frameworks/PyMongo.qll index de36f2ebcec9..fd204a22a702 100644 --- a/python/ql/lib/semmle/python/frameworks/PyMongo.qll +++ b/python/ql/lib/semmle/python/frameworks/PyMongo.qll @@ -128,6 +128,15 @@ private module PyMongo { override predicate vulnerableToStrings() { none() } } + /** A call that reads documents from a MongoDB collection. */ + private class MongoCollectionRead extends ThreatModelSource::Range { + MongoCollectionRead() { this instanceof MongoCollectionCall } + + override string getThreatModel() { result = "database" } + + override string getSourceType() { result = "MongoDB collection read" } + } + /** * See https://pymongo.readthedocs.io/en/stable/api/pymongo/collection.html#pymongo.collection.Collection.aggregate */ diff --git a/python/ql/test/library-tests/frameworks/pymongo/ConceptsTest.expected b/python/ql/test/library-tests/frameworks/pymongo/ConceptsTest.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/frameworks/pymongo/ConceptsTest.ql b/python/ql/test/library-tests/frameworks/pymongo/ConceptsTest.ql new file mode 100644 index 000000000000..b557a0bccb69 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/pymongo/ConceptsTest.ql @@ -0,0 +1,2 @@ +import python +import experimental.meta.ConceptsTest diff --git a/python/ql/test/library-tests/frameworks/pymongo/DefaultSources.expected b/python/ql/test/library-tests/frameworks/pymongo/DefaultSources.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/frameworks/pymongo/DefaultSources.ql b/python/ql/test/library-tests/frameworks/pymongo/DefaultSources.ql new file mode 100644 index 000000000000..a49dfce05fae --- /dev/null +++ b/python/ql/test/library-tests/frameworks/pymongo/DefaultSources.ql @@ -0,0 +1,6 @@ +import python +import semmle.python.Concepts + +from ActiveThreatModelSource source +where source.getThreatModel() = "database" +select source diff --git a/python/ql/test/library-tests/frameworks/pymongo/reads.py b/python/ql/test/library-tests/frameworks/pymongo/reads.py new file mode 100644 index 000000000000..c77df443a68a --- /dev/null +++ b/python/ql/test/library-tests/frameworks/pymongo/reads.py @@ -0,0 +1,39 @@ +from pymongo import MongoClient +from pymongo.mongo_client import MongoClient as AliasedMongoClient +from flask_pymongo import PyMongo + +client = MongoClient() +collection = client.db.users + +collection.find({}) # $ threatModelSource[database]=collection.find(..) +collection.find_raw_batches({}) # $ threatModelSource[database]=collection.find_raw_batches(..) +collection.find_one({}) # $ threatModelSource[database]=collection.find_one(..) +collection.find_one_and_delete({}) # $ threatModelSource[database]=collection.find_one_and_delete(..) +collection.find_one_and_replace({}, {}) # $ threatModelSource[database]=collection.find_one_and_replace(..) +collection.find_one_and_update({}, {}) # $ threatModelSource[database]=collection.find_one_and_update(..) +collection.find_and_modify({}) # $ threatModelSource[database]=collection.find_and_modify(..) + +# Collection access through subscripts and accessor methods. +client['db']['users'].find_one({}) # $ threatModelSource[database]=client['db']['users'].find_one(..) +client.get_database('db').get_collection('users').find_one({}) # $ threatModelSource[database]=client.get_database(..).get_collection(..).find_one(..) +client.get_default_database().users.find_one({}) # $ threatModelSource[database]=client.get_default_database().users.find_one(..) +AliasedMongoClient().db.users.find_one({}) # $ threatModelSource[database]=AliasedMongoClient().db.users.find_one(..) + +# Flask-PyMongo exposes the same collection APIs and find_one_or_404. +mongo = PyMongo() +mongo.db.users.find_one({}) # $ threatModelSource[database]=mongo.db.users.find_one(..) +mongo.db.users.find_one_or_404({}) # $ threatModelSource[database]=mongo.db.users.find_one_or_404(..) + +# Write results and counts are not document reads. +collection.insert_one({'user': 'alice'}) +collection.delete_one({}) +collection.count_documents({}) + + +class UnrelatedCollection: + def find_one(self, query): + return {'user': 'alice'} + + +# A method name alone does not make a database source. +UnrelatedCollection().find_one({}) diff --git a/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.expected b/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.expected new file mode 100644 index 000000000000..31e8cf5e6080 --- /dev/null +++ b/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.expected @@ -0,0 +1,64 @@ +edges +| pymongo_reads.py:7:5:7:10 | ControlFlowNode for record | pymongo_reads.py:9:20:9:78 | ControlFlowNode for BinaryExpr | provenance | | +| pymongo_reads.py:7:14:7:48 | ControlFlowNode for Attribute() | pymongo_reads.py:7:5:7:10 | ControlFlowNode for record | provenance | | +| pymongo_reads.py:16:9:16:14 | ControlFlowNode for record | pymongo_reads.py:17:24:17:82 | ControlFlowNode for BinaryExpr | provenance | | +| pymongo_reads.py:16:19:16:37 | ControlFlowNode for Attribute() | pymongo_reads.py:16:9:16:14 | ControlFlowNode for record | provenance | | +| pymongo_reads.py:25:9:25:14 | ControlFlowNode for record | pymongo_reads.py:26:22:26:35 | ControlFlowNode for Subscript | provenance | | +| pymongo_reads.py:25:19:25:80 | ControlFlowNode for Attribute() | pymongo_reads.py:25:9:25:14 | ControlFlowNode for record | provenance | | +| pymongo_reads.py:26:9:26:13 | [post] ControlFlowNode for users [List element] | pymongo_reads.py:27:29:27:33 | ControlFlowNode for users [List element] | provenance | | +| pymongo_reads.py:26:22:26:35 | ControlFlowNode for Subscript | pymongo_reads.py:26:9:26:13 | [post] ControlFlowNode for users [List element] | provenance | list.append | +| pymongo_reads.py:27:5:27:13 | ControlFlowNode for in_clause | pymongo_reads.py:28:5:28:7 | ControlFlowNode for sql | provenance | | +| pymongo_reads.py:27:17:27:34 | ControlFlowNode for Attribute() | pymongo_reads.py:27:5:27:13 | ControlFlowNode for in_clause | provenance | | +| pymongo_reads.py:27:29:27:33 | ControlFlowNode for users [List element] | pymongo_reads.py:27:17:27:34 | ControlFlowNode for Attribute() | provenance | str.join | +| pymongo_reads.py:28:5:28:7 | ControlFlowNode for sql | pymongo_reads.py:30:20:30:22 | ControlFlowNode for sql | provenance | | +| pymongo_reads.py:35:5:35:10 | ControlFlowNode for record | pymongo_reads.py:36:41:36:66 | ControlFlowNode for BinaryExpr | provenance | | +| pymongo_reads.py:35:14:35:59 | ControlFlowNode for Attribute() | pymongo_reads.py:35:5:35:10 | ControlFlowNode for record | provenance | | +| pymongo_reads.py:40:5:40:10 | ControlFlowNode for record | pymongo_reads.py:41:41:41:66 | ControlFlowNode for BinaryExpr | provenance | | +| pymongo_reads.py:40:14:40:79 | ControlFlowNode for Attribute() | pymongo_reads.py:40:5:40:10 | ControlFlowNode for record | provenance | | +| pymongo_reads.py:45:5:45:10 | ControlFlowNode for record | pymongo_reads.py:46:41:46:66 | ControlFlowNode for BinaryExpr | provenance | | +| pymongo_reads.py:45:14:45:88 | ControlFlowNode for Attribute() | pymongo_reads.py:45:5:45:10 | ControlFlowNode for record | provenance | | +| pymongo_reads.py:51:5:51:10 | ControlFlowNode for record | pymongo_reads.py:52:41:52:66 | ControlFlowNode for BinaryExpr | provenance | | +| pymongo_reads.py:51:14:51:36 | ControlFlowNode for Attribute() | pymongo_reads.py:51:5:51:10 | ControlFlowNode for record | provenance | | +| pymongo_reads.py:56:5:56:10 | ControlFlowNode for record | pymongo_reads.py:57:41:57:66 | ControlFlowNode for BinaryExpr | provenance | | +| pymongo_reads.py:56:14:56:51 | ControlFlowNode for Attribute() | pymongo_reads.py:56:5:56:10 | ControlFlowNode for record | provenance | | +nodes +| pymongo_reads.py:7:5:7:10 | ControlFlowNode for record | semmle.label | ControlFlowNode for record | +| pymongo_reads.py:7:14:7:48 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:9:20:9:78 | ControlFlowNode for BinaryExpr | semmle.label | ControlFlowNode for BinaryExpr | +| pymongo_reads.py:16:9:16:14 | ControlFlowNode for record | semmle.label | ControlFlowNode for record | +| pymongo_reads.py:16:19:16:37 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:17:24:17:82 | ControlFlowNode for BinaryExpr | semmle.label | ControlFlowNode for BinaryExpr | +| pymongo_reads.py:25:9:25:14 | ControlFlowNode for record | semmle.label | ControlFlowNode for record | +| pymongo_reads.py:25:19:25:80 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:26:9:26:13 | [post] ControlFlowNode for users [List element] | semmle.label | [post] ControlFlowNode for users [List element] | +| pymongo_reads.py:26:22:26:35 | ControlFlowNode for Subscript | semmle.label | ControlFlowNode for Subscript | +| pymongo_reads.py:27:5:27:13 | ControlFlowNode for in_clause | semmle.label | ControlFlowNode for in_clause | +| pymongo_reads.py:27:17:27:34 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:27:29:27:33 | ControlFlowNode for users [List element] | semmle.label | ControlFlowNode for users [List element] | +| pymongo_reads.py:28:5:28:7 | ControlFlowNode for sql | semmle.label | ControlFlowNode for sql | +| pymongo_reads.py:30:20:30:22 | ControlFlowNode for sql | semmle.label | ControlFlowNode for sql | +| pymongo_reads.py:35:5:35:10 | ControlFlowNode for record | semmle.label | ControlFlowNode for record | +| pymongo_reads.py:35:14:35:59 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:36:41:36:66 | ControlFlowNode for BinaryExpr | semmle.label | ControlFlowNode for BinaryExpr | +| pymongo_reads.py:40:5:40:10 | ControlFlowNode for record | semmle.label | ControlFlowNode for record | +| pymongo_reads.py:40:14:40:79 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:41:41:41:66 | ControlFlowNode for BinaryExpr | semmle.label | ControlFlowNode for BinaryExpr | +| pymongo_reads.py:45:5:45:10 | ControlFlowNode for record | semmle.label | ControlFlowNode for record | +| pymongo_reads.py:45:14:45:88 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:46:41:46:66 | ControlFlowNode for BinaryExpr | semmle.label | ControlFlowNode for BinaryExpr | +| pymongo_reads.py:51:5:51:10 | ControlFlowNode for record | semmle.label | ControlFlowNode for record | +| pymongo_reads.py:51:14:51:36 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:52:41:52:66 | ControlFlowNode for BinaryExpr | semmle.label | ControlFlowNode for BinaryExpr | +| pymongo_reads.py:56:5:56:10 | ControlFlowNode for record | semmle.label | ControlFlowNode for record | +| pymongo_reads.py:56:14:56:51 | ControlFlowNode for Attribute() | semmle.label | ControlFlowNode for Attribute() | +| pymongo_reads.py:57:41:57:66 | ControlFlowNode for BinaryExpr | semmle.label | ControlFlowNode for BinaryExpr | +subpaths +#select +| pymongo_reads.py:9:20:9:78 | ControlFlowNode for BinaryExpr | pymongo_reads.py:7:14:7:48 | ControlFlowNode for Attribute() | pymongo_reads.py:9:20:9:78 | ControlFlowNode for BinaryExpr | This SQL query depends on a $@. | pymongo_reads.py:7:14:7:48 | ControlFlowNode for Attribute() | user-provided value | +| pymongo_reads.py:17:24:17:82 | ControlFlowNode for BinaryExpr | pymongo_reads.py:16:19:16:37 | ControlFlowNode for Attribute() | pymongo_reads.py:17:24:17:82 | ControlFlowNode for BinaryExpr | This SQL query depends on a $@. | pymongo_reads.py:16:19:16:37 | ControlFlowNode for Attribute() | user-provided value | +| pymongo_reads.py:30:20:30:22 | ControlFlowNode for sql | pymongo_reads.py:25:19:25:80 | ControlFlowNode for Attribute() | pymongo_reads.py:30:20:30:22 | ControlFlowNode for sql | This SQL query depends on a $@. | pymongo_reads.py:25:19:25:80 | ControlFlowNode for Attribute() | user-provided value | +| pymongo_reads.py:36:41:36:66 | ControlFlowNode for BinaryExpr | pymongo_reads.py:35:14:35:59 | ControlFlowNode for Attribute() | pymongo_reads.py:36:41:36:66 | ControlFlowNode for BinaryExpr | This SQL query depends on a $@. | pymongo_reads.py:35:14:35:59 | ControlFlowNode for Attribute() | user-provided value | +| pymongo_reads.py:41:41:41:66 | ControlFlowNode for BinaryExpr | pymongo_reads.py:40:14:40:79 | ControlFlowNode for Attribute() | pymongo_reads.py:41:41:41:66 | ControlFlowNode for BinaryExpr | This SQL query depends on a $@. | pymongo_reads.py:40:14:40:79 | ControlFlowNode for Attribute() | user-provided value | +| pymongo_reads.py:46:41:46:66 | ControlFlowNode for BinaryExpr | pymongo_reads.py:45:14:45:88 | ControlFlowNode for Attribute() | pymongo_reads.py:46:41:46:66 | ControlFlowNode for BinaryExpr | This SQL query depends on a $@. | pymongo_reads.py:45:14:45:88 | ControlFlowNode for Attribute() | user-provided value | +| pymongo_reads.py:52:41:52:66 | ControlFlowNode for BinaryExpr | pymongo_reads.py:51:14:51:36 | ControlFlowNode for Attribute() | pymongo_reads.py:52:41:52:66 | ControlFlowNode for BinaryExpr | This SQL query depends on a $@. | pymongo_reads.py:51:14:51:36 | ControlFlowNode for Attribute() | user-provided value | +| pymongo_reads.py:57:41:57:66 | ControlFlowNode for BinaryExpr | pymongo_reads.py:56:14:56:51 | ControlFlowNode for Attribute() | pymongo_reads.py:57:41:57:66 | ControlFlowNode for BinaryExpr | This SQL query depends on a $@. | pymongo_reads.py:56:14:56:51 | ControlFlowNode for Attribute() | user-provided value | diff --git a/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.ext.yml b/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.ext.yml new file mode 100644 index 000000000000..e6f9a7b64d44 --- /dev/null +++ b/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.ext.yml @@ -0,0 +1,6 @@ +extensions: + - addsTo: + pack: codeql/threat-models + extensible: threatModelConfiguration + data: + - ["database", true, 0] diff --git a/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.qlref b/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.qlref new file mode 100644 index 000000000000..d1d02cbe8d37 --- /dev/null +++ b/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/SqlInjection.qlref @@ -0,0 +1 @@ +Security/CWE-089/SqlInjection.ql diff --git a/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/pymongo_reads.py b/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/pymongo_reads.py new file mode 100644 index 000000000000..1d1606a798e4 --- /dev/null +++ b/python/ql/test/query-tests/Security/CWE-089-SqlInjection-database-threat-model/pymongo_reads.py @@ -0,0 +1,67 @@ +import sqlite3 +from pymongo import MongoClient +from flask_pymongo import PyMongo + + +def find_one(): + record = MongoClient().db.users.find_one({}) + cursor = sqlite3.connect(':memory:').cursor() + cursor.execute("SELECT * FROM users WHERE name = '" + record['user'] + "'") # $ Alert + cursor.execute('SELECT * FROM users WHERE name = ?', (record['user'],)) + + +def find_iteration(): + collection = MongoClient().db.users + cursor = sqlite3.connect(':memory:').cursor() + for record in collection.find({}): + cursor.execute("SELECT * FROM users WHERE name = '" + record['user'] + "'") # $ Alert + cursor.execute('SELECT * FROM users WHERE name = ?', (record['user'],)) + + +def sync_users(): + # Regression for the list accumulation and join in issue #21775. + users = [] + client = MongoClient('mongodb://localhost:27017/') + for record in client.auth.users.find({'role': 'dev'}, {'user': 1, '_id': 0}): + users.append(record['user']) + in_clause = "', '".join(users) + sql = "SELECT * FROM user_info WHERE username IN ('" + in_clause + "')" + cursor = sqlite3.connect(':memory:').cursor() + cursor.execute(sql) # $ Alert + cursor.execute('SELECT * FROM user_info WHERE username = ?', (in_clause,)) + + +def find_and_delete(): + record = MongoClient().db.users.find_one_and_delete({}) + sqlite3.connect(':memory:').execute('SELECT ' + record['user']) # $ Alert + + +def find_and_replace(): + record = MongoClient().db.users.find_one_and_replace({}, {'user': 'alice'}) + sqlite3.connect(':memory:').execute('SELECT ' + record['user']) # $ Alert + + +def find_and_update(): + record = MongoClient().db.users.find_one_and_update({}, {'$set': {'user': 'alice'}}) + sqlite3.connect(':memory:').execute('SELECT ' + record['user']) # $ Alert + + +def collection_accessors(): + collection = MongoClient().get_database('db').get_collection('users') + record = collection.find_one({}) + sqlite3.connect(':memory:').execute('SELECT ' + record['user']) # $ Alert + + +def flask_pymongo(): + record = PyMongo().db.users.find_one_or_404({}) + sqlite3.connect(':memory:').execute('SELECT ' + record['user']) # $ Alert + + +class UnrelatedCollection: + def find_one(self, query): + return {'user': 'alice'} + + +def unrelated_method(): + record = UnrelatedCollection().find_one({}) + sqlite3.connect(':memory:').execute('SELECT ' + record['user'])