Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 22 additions & 8 deletions src/coretrace_python/semantic/symbols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]):
Expand Down
49 changes: 47 additions & 2 deletions tests/test_frameworks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
Loading