From 60e381c621a5a4155c2692e063313d4dfce15836 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 26 Sep 2026 00:06:04 +0200 Subject: [PATCH 1/2] test: expect module-level names bound through an instance to resolve --- tests/test_frameworks.py | 49 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/tests/test_frameworks.py b/tests/test_frameworks.py index a1afdac..019fa4d 100644 --- a/tests/test_frameworks.py +++ b/tests/test_frameworks.py @@ -3,8 +3,9 @@ Framework plugins enrich the security model; detectors stay generic. Three engine mechanisms make that possible: -- a module-level ``name = Symbol(...)`` binds ``name`` to that symbol, so ``app.route`` - resolves to ``python.flask.Flask.route`` when ``app = Flask(__name__)``; +- a module-level name denotes what its value denotes in a function body, so ``app.route`` + resolves to ``python.flask.Flask.route`` when ``app = Flask(__name__)``, and + ``cursor = conn.cursor()`` goes through the ``conn`` bound before it; - inside functions, call results and ``with`` contexts carry their callee's symbol, so ``conn.cursor().execute`` resolves to ``python.sqlite3.connect.cursor.execute``; - an ``EntryPoint`` model taints every parameter of a function decorated by its symbol, @@ -89,6 +90,50 @@ def test_module_level_instances_resolve_to_their_class_symbol() -> None: assert symbols.resolve(f.id, "limit") is None +def test_module_level_names_bound_through_an_instance_denote_what_a_function_derives() -> None: + manager = manager_for( + "import os\nimport sqlite3\nfrom django.db import connections\n\n" + "early = conn.cursor()\n" + "conn = sqlite3.connect('app.db')\n" + "cursor = conn.cursor()\n" + "same = conn\n" + "run = os.system\n" + "home = os.environ['HOME']\n" + "raw = connections['default'].cursor()\n" + "limit = 3\n\n" + "def f():\n return cursor\n" + ) + symbols = manager.get(SymbolAnalysis) + scopes = manager.get(ScopeAnalysis) + f = next(s for s in scopes.children(scopes.module_scope.id) if s.name == "f") + + assert {name: symbols.resolve(f.id, name) for name in ("cursor", "same", "run", "home", "raw")} == { + "cursor": SymbolId("python.sqlite3.connect.cursor"), + "same": SymbolId("python.sqlite3.connect"), + "run": SymbolId("python.os.system"), + # An item carries its container's symbol, as in a function body. + "home": SymbolId("python.os.environ"), + "raw": SymbolId("python.django.db.connections.cursor"), + } + # Names resolve in statement order: ``conn`` is not bound yet when ``early`` is. + assert symbols.resolve(f.id, "early") is None + assert symbols.resolve(f.id, "limit") is None + + +def test_a_query_through_a_module_level_cursor_is_an_injection() -> None: + findings = check( + "import sqlite3\nfrom flask import Flask, request\n\n" + "app = Flask(__name__)\n" + "conn = sqlite3.connect('app.db')\n" + "cursor = conn.cursor()\n\n" + "@app.route('/users')\n" + "def users():\n" + " return str(cursor.execute(\"SELECT * FROM users WHERE name = '\" + request.args['q'] + \"'\").fetchall())\n" + ) + + assert [(f.rule_id, f.span.start_line) for f in findings if f.rule_id == "sql-injection"] == [("sql-injection", 10)] + + def test_attributes_of_module_level_instances_lower_to_symbols() -> None: module = module_for("from flask import Flask\napp = Flask(__name__)\n\ndef f():\n return app.route\n") output = format_module(lower_module(module)) From 66802d1ffc0653a43598a2dac3f52761cf5aeace Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 26 Sep 2026 00:06:04 +0200 Subject: [PATCH 2/2] fix: resolve module-level names bound through another instance A module-level name denotes what its value denotes in a function body: an alias, an attribute, an item or a call of an import or of an earlier module-level name, resolved in statement order. --- CHANGELOG.md | 6 +++++ src/coretrace_python/semantic/symbols.py | 30 +++++++++++++++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bccc191..722ab03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Detection + +- A module-level name bound to an attribute, an item or a call of another module-level name resolves in functions, as the same value does in a function body. `cursor = conn.cursor()` after `conn = sqlite3.connect(...)`, `db = client.shop` and `run = os.system` used to leave the calls made through them unknown, so a query built from request data and executed through a module-level cursor was not reported. Names resolve in statement order. The regression corpus is unchanged (#155). + ## 0.11.0 (2026-09-25) ### Detection diff --git a/src/coretrace_python/semantic/symbols.py b/src/coretrace_python/semantic/symbols.py index 20d3de4..ed065bf 100644 --- a/src/coretrace_python/semantic/symbols.py +++ b/src/coretrace_python/semantic/symbols.py @@ -18,7 +18,8 @@ class SymbolTable: """Resolve names to canonical symbols through scopes, imports, builtins and the - module-level instances created by calling a symbol (``app = Flask(__name__)``).""" + module-level names bound to a value denoting a symbol (``app = Flask(__name__)``, + ``cursor = conn.cursor()``).""" def __init__( self, @@ -64,15 +65,28 @@ def analyze_symbols( instances: dict[str, SymbolId] = {} module_scope = scopes.module_scope.id for statement in module.body: - if ( - isinstance(statement, nodes.Assign) - and isinstance(statement.target, nodes.Name) - and isinstance(statement.value, nodes.Call) - ): - symbol = table.resolve_expression(module_scope, statement.value.callee) + if isinstance(statement, nodes.Assign) and isinstance(statement.target, nodes.Name): + symbol = _denoted(table, module_scope, statement.value) if symbol is not None: instances[statement.target.identifier] = symbol - return SymbolTable(scopes, imports, instances) + # Later bindings resolve through this one, in statement order. + table = SymbolTable(scopes, imports, instances) + return table + + +def _denoted(table: SymbolTable, scope_id: ScopeId, node: nodes.Expression) -> SymbolId | None: + """What a value denotes, as a function body derives it (``derive_symbols``): a + resolved name or an attribute of one, the result of calling one, or an item of one, + which carries its container's symbol (``os.environ['HOME']``).""" + + if isinstance(node, nodes.Subscript): + return _denoted(table, scope_id, node.value) + if isinstance(node, nodes.Attribute): + parent = _denoted(table, scope_id, node.value) + return parent.attribute(node.name) if parent is not None else None + if isinstance(node, nodes.Call): + return _denoted(table, scope_id, node.callee) + return table.resolve_expression(scope_id, node) class SymbolAnalysis(Analysis[SymbolTable]):