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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# 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).
- `nosql-injection` reports attacker input that may hold a structure reaching a NoSQL sink, such as a MongoDB filter, where a JSON body giving `{"$ne": null}` matches any value. Only input that may hold a structure carries `NOSQL`: JSON, the raw bodies a `json.loads` decodes, files, WebSocket messages and standard input. Text cannot hold an operator. The text sources of Flask, bottle and tornado, the URL parameters of their routes, the command line, the environment and `input()` no longer carry `NOSQL`. Neither does an entry-point parameter annotated with scalars (`str`, `Optional[int]`, `Annotated[str, Query()]`, `list[str]`, …), which FastAPI validates. Two limits remain. Django and aiohttp request objects stay tainted as a whole. The keys of a dict are not followed, so a string concatenated into `$where` is not reported. The engine ships no NoSQL sink: model plugins declare them (#158).

### Plugins

- `TEXT_KINDS` holds every kind but `NOSQL`: what text input carries. A model plugin declares its text sources with it (#158).

- A `Members(symbol, dynamic=None, defined=(), typed=())` model says what the attributes and items of a class's instances give, for libraries whose objects give others by names the project chooses. A pymongo client gives the database it is dotted or indexed with, a database gives a collection. The names used to end up in the symbol (`MongoClient.shop.users.find`), or an item vanished into its container's symbol (`MongoClient.find`), so no model could list them. With the model, every path to a collection denotes the collection class, the one a parameter annotated `Collection` denotes: `client.shop.users`, `client["shop"]["users"]`, `client.get_database("shop").get_collection("users")`, a module-level `db = client.shop`, an attribute inherited from the client class. A sink on `Collection.find` covers them all. Symbol resolution now depends on these models, which the engine provides to every module and worker (#157).

## 0.11.0 (2026-09-25)

### Detection
Expand Down
3 changes: 2 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ class BottleModels(ModelPlugin):

| Model | Declares |
|---|---|
| `Source(symbol, label, kinds=ALL)` | A call or attribute whose value is attacker-controlled; `label` names it in messages (`http`, `stdin`, `argv`, `http-response`). `kinds` restricts what it can inject. |
| `Source(symbol, label, kinds=ALL)` | A call or attribute whose value is attacker-controlled; `label` names it in messages (`http`, `stdin`, `argv`, `http-response`). `kinds` restricts what it can inject. Text input, such as a form field, a header or a URL parameter, carries `TEXT_KINDS`, every kind but `NOSQL`: a string cannot hold a query operator. |
| `Sink(symbol, kinds, positions=(), keywords=())` | A call dangerous for `kinds`. `positions` limits a kind to the given argument indexes, and `keywords` to the given keyword names; a restricted kind reaches only the arguments listed there. SQL sinks read the statement only, and the SSRF sinks of HTTP clients read the URL, by position or as `url=`. A starred argument or `**kwargs` reaches no restricted kind. |
| `Sanitizer(symbol, kinds)` | A call whose result is safe for `kinds`, including when the call is inside a project function the data goes through. |
| `EntryPoint(symbol, label, kinds=ALL)` | A decorator or a class base whose functions receive their parameters as `label` input, such as `flask.Flask.route`. |
Expand All @@ -152,6 +152,7 @@ class BottleModels(ModelPlugin):
| `TemplateRender(symbol, position=0, keyword="template_name")` | A call rendering, with autoescaping, the template named by its argument at `position` or `keyword`, such as `render_to_string`. What it returns carries no `HTML` when the name is written in the call and the project's template escapes everything it prints (see the usage guide). |
| `Validator(symbol, kinds=ALL, argument=0)` | A callable whose truth proves its argument safe for `kinds`, such as `re.fullmatch`. A flow guarded by it is refuted when it carries no other kind into the sink, and is a hotspot otherwise: a redirect validator does not make a shell command safe. `symbol` may be a function of the project, by its project symbol (`python.hc.accounts.views._allow_redirect`), which then counts wherever it is called, in its own module too. |
| `AuthorizationGuard(symbol, label)` | A decorator or a condition restricting who reaches the code, such as `login_required`; a flow behind it is a hotspot. |
| `Members(symbol, dynamic=None, defined=(), typed=())` | What the attributes and items of an instance of the class `symbol` give, for objects that give others by name. `typed` maps an attribute to the class of what it gives, called or not, since a call denotes its callee: pymongo's `get_database` gives a `Database`, as Flask-PyMongo's `db` property does. `dynamic`, when set, is the class of every item and of every attribute outside `defined` and `typed`: a pymongo client gives a `Database` by name (`client.shop`, `client["shop"]`), a database a `Collection`. Every path to a collection then denotes the collection class, so a sink on `Collection.find` covers them all. Without `dynamic`, an attribute keeps its path and an item its container's symbol, as for any class. |

Two plugins may describe the same symbol. An identical model is registered once; two
`Sink` models merge their kinds, positions and keywords, so a plugin can add `SQL` to a sink
Expand Down
1 change: 1 addition & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ variable is not treated that way yet.
| `command-injection` | Shell or process execution (`os.system`, `subprocess` with a string, …). |
| `code-injection` | The code `eval` or `exec` runs; the globals and locals passed with it are data. `dangerous-eval` reports every such call, this rule the ones attacker input reaches. |
| `sql-injection` | A database statement (`cursor.execute`, SQLAlchemy `text`, Django `raw`, …). Parameters of a parameterised query are not statements. |
| `nosql-injection` | A NoSQL query, such as a MongoDB filter, given attacker input that may hold a structure: a JSON body giving `{"$ne": null}` matches any value. The input that may hold a structure is JSON, the raw bodies a `json.loads` decodes, files, WebSocket messages and standard input. Text cannot hold an operator: form fields, query parameters, headers, cookies, URL parameters, the command line, the environment, and an entry-point parameter annotated with scalars (`str`, `int`, `Optional[str]`, `Annotated[str, Query()]`, `list[str]`, …), which FastAPI validates. Limits: a Django or aiohttp request object is tainted as a whole, so `request.GET` counts like `request.body`; the keys of a dict are not followed, so a string concatenated into `$where` is not reported. The analyzer ships no NoSQL sink: model plugins declare them. |
| `path-traversal` | A file system path (`open`, `send_file`, `os.remove`, …). |
| `ssrf` | The URL of an HTTP client request (Requests, httpx, `urllib`), by position or as `url=`; the body, headers and query parameters are not the destination. |
| `xss` | An HTTP response body built without escaping. A Django template rendered by `render_to_string` escapes, where the analyzer can establish it (below). |
Expand Down
16 changes: 10 additions & 6 deletions src/coretrace_python/bundled/models/bottle/bottle_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@

from coretrace_python.plugins import ModelPlugin
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.taint import EntryPoint, Model, Sanitizer, Sink, Source, TaintKind
from coretrace_python.taint import TEXT_KINDS, EntryPoint, Model, Sanitizer, Sink, Source, TaintKind

_METHODS = ("route", "get", "post", "put", "delete", "patch")
_REQUEST_ATTRIBUTES = (
"query", "forms", "params", "json", "body", "headers", "cookies", "GET", "POST", "files",
# Text, which cannot hold a query operator; JSON, raw bodies and files may hold a structure.
_TEXT_ATTRIBUTES = (
"query", "forms", "params", "headers", "cookies", "GET", "POST",
"url", "path", "fullpath", "query_string", "get_cookie", "get_header",
)
_PAYLOAD_ATTRIBUTES = ("json", "body", "files")
_FIRST_ONLY = lambda kind: ((kind, (0,)),)


Expand All @@ -25,9 +27,11 @@ def _sym(path: str) -> SymbolId:
class BottleModels(ModelPlugin):
name: ClassVar[str] = "bottle-models"
models: ClassVar[tuple[Model, ...]] = (
*(EntryPoint(_sym(f"bottle.{method}"), "http") for method in _METHODS),
*(EntryPoint(_sym(f"bottle.Bottle.{method}"), "http") for method in _METHODS),
*(Source(_sym(f"bottle.request.{attribute}"), "http") for attribute in _REQUEST_ATTRIBUTES),
# A route's parameters are segments of its URL.
*(EntryPoint(_sym(f"bottle.{method}"), "http", TEXT_KINDS) for method in _METHODS),
*(EntryPoint(_sym(f"bottle.Bottle.{method}"), "http", TEXT_KINDS) for method in _METHODS),
*(Source(_sym(f"bottle.request.{attribute}"), "http", TEXT_KINDS) for attribute in _TEXT_ATTRIBUTES),
*(Source(_sym(f"bottle.request.{attribute}"), "http") for attribute in _PAYLOAD_ATTRIBUTES),
Sink(_sym("bottle.redirect"), TaintKind.REDIRECT, _FIRST_ONLY(TaintKind.REDIRECT)),
# ``static_file(filename, root)``: the root is the application's own.
Sink(_sym("bottle.static_file"), TaintKind.PATH, _FIRST_ONLY(TaintKind.PATH)),
Expand Down
8 changes: 4 additions & 4 deletions src/coretrace_python/bundled/models/cli/cli_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

from coretrace_python.plugins import ModelPlugin
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.taint import EntryPoint, Model, TaintKind
from coretrace_python.taint import TEXT_KINDS, EntryPoint, Model, TaintKind

# A command-line tool is expected to open the paths it is given: argv input carries every
# kind but PATH.
ARGV_KINDS = TaintKind.ALL & ~TaintKind.PATH
# A command-line tool is expected to open the paths it is given: argv input is text,
# without PATH.
ARGV_KINDS = TEXT_KINDS & ~TaintKind.PATH

_COMMANDS = (
"click.command",
Expand Down
21 changes: 12 additions & 9 deletions src/coretrace_python/bundled/models/flask/flask_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from coretrace_python.plugins import ModelPlugin
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.taint import (
TEXT_KINDS,
AuthorizationGuard,
EntryPoint,
Model,
Expand All @@ -17,10 +18,10 @@
TaintKind,
)

_REQUEST_ATTRIBUTES = (
"args", "form", "values", "json", "data", "cookies", "headers", "files",
"get_json", "get_data", "url", "full_path", "path", "query_string", "stream",
)
# Strings, or mappings of strings: text, which cannot hold a query operator.
_TEXT_ATTRIBUTES = ("args", "form", "values", "cookies", "headers", "url", "full_path", "path", "query_string")
# JSON, and the raw bodies and files a ``json.loads`` may decode into a structure.
_PAYLOAD_ATTRIBUTES = ("json", "get_json", "data", "get_data", "stream", "files")


_TARGET_ONLY = ((TaintKind.REDIRECT, (0,)),)
Expand All @@ -33,11 +34,13 @@ def _sym(path: str) -> SymbolId:
class FlaskModels(ModelPlugin):
name: ClassVar[str] = "flask-models"
models: ClassVar[tuple[Model, ...]] = (
*(Source(_sym(f"flask.request.{attribute}"), "http") for attribute in _REQUEST_ATTRIBUTES),
EntryPoint(_sym("flask.Flask.route"), "http"),
EntryPoint(_sym("flask.Blueprint.route"), "http"),
RouteRegistrar(_sym("flask.Flask.add_url_rule"), 2, "http", keyword="view_func"),
RouteRegistrar(_sym("flask.Blueprint.add_url_rule"), 2, "http", keyword="view_func"),
*(Source(_sym(f"flask.request.{attribute}"), "http", TEXT_KINDS) for attribute in _TEXT_ATTRIBUTES),
*(Source(_sym(f"flask.request.{attribute}"), "http") for attribute in _PAYLOAD_ATTRIBUTES),
# A route's parameters are segments of its URL.
EntryPoint(_sym("flask.Flask.route"), "http", TEXT_KINDS),
EntryPoint(_sym("flask.Blueprint.route"), "http", TEXT_KINDS),
RouteRegistrar(_sym("flask.Flask.add_url_rule"), 2, "http", TEXT_KINDS, keyword="view_func"),
RouteRegistrar(_sym("flask.Blueprint.add_url_rule"), 2, "http", TEXT_KINDS, keyword="view_func"),
Sink(_sym("flask.render_template_string"), TaintKind.HTML),
Sink(_sym("flask.make_response"), TaintKind.HTML),
Sink(_sym("flask.Response"), TaintKind.HTML),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from coretrace_python.plugins import ModelPlugin
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.taint import (
TEXT_KINDS,
Model,
SafeArgument,
Sanitizer,
Expand All @@ -16,7 +17,7 @@
Validator,
)

_ENVIRONMENT_KINDS = TaintKind.ALL & ~(TaintKind.COMMAND | TaintKind.PATH)
_ENVIRONMENT_KINDS = TEXT_KINDS & ~(TaintKind.COMMAND | TaintKind.PATH)
# SafeLoader and BaseLoader build plain data only, in Python and in C, under every spelling.
_SAFE_YAML_LOADERS = (
"python.yaml.SafeLoader",
Expand All @@ -38,13 +39,14 @@ def _sym(path: str) -> SymbolId:
class PythonStdlibModels(ModelPlugin):
name: ClassVar[str] = "python-stdlib-models"
models: ClassVar[tuple[Model, ...]] = (
Source(_sym("builtins.input"), "stdin"),
# A line of text; the stream may be a payload ``json.load`` decodes.
Source(_sym("builtins.input"), "stdin", TEXT_KINDS),
Source(_sym("sys.stdin"), "stdin"),
# Operator-controlled inputs. A command-line tool is expected to open the paths
# it is given; the environment is set by whoever runs the program, so a command
# or a path built from it is not an injection; the output of a local process is
# not a path either, but a downloaded script piped into a shell is a real flaw.
Source(_sym("sys.argv"), "argv", TaintKind.ALL & ~TaintKind.PATH),
Source(_sym("sys.argv"), "argv", TEXT_KINDS & ~TaintKind.PATH),
Source(_sym("os.environ"), "environment", _ENVIRONMENT_KINDS),
Source(_sym("subprocess.run.stdout"), "process-output", _PROCESS_OUTPUT_KINDS),
Source(_sym("subprocess.check_output"), "process-output", _PROCESS_OUTPUT_KINDS),
Expand Down
15 changes: 10 additions & 5 deletions src/coretrace_python/bundled/models/tornado/tornado_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@

from coretrace_python.plugins import ModelPlugin
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.taint import EntryPoint, Model, Sanitizer, Sink, Source, TaintKind
from coretrace_python.taint import TEXT_KINDS, EntryPoint, Model, Sanitizer, Sink, Source, TaintKind

_HANDLERS = ("tornado.web.RequestHandler", "tornado.websocket.WebSocketHandler")
_REQUEST_METHODS = (
# Text, which cannot hold a query operator; the request object holds the raw body too.
_TEXT_METHODS = (
"get_argument", "get_arguments", "get_query_argument", "get_query_arguments",
"get_body_argument", "get_body_arguments", "get_cookie", "request", "path_args", "path_kwargs",
"get_body_argument", "get_body_arguments", "get_cookie", "path_args", "path_kwargs",
)
_CLIENTS = ("tornado.httpclient.AsyncHTTPClient.fetch", "tornado.httpclient.HTTPClient.fetch")
_TARGET_ONLY = ((TaintKind.REDIRECT, (0,)),)
Expand All @@ -26,8 +27,12 @@ def _sym(path: str) -> SymbolId:
class TornadoModels(ModelPlugin):
name: ClassVar[str] = "tornado-models"
models: ClassVar[tuple[Model, ...]] = (
*(EntryPoint(_sym(handler), "http") for handler in _HANDLERS),
*(Source(_sym(f"{handler}.{method}"), "http") for handler in _HANDLERS for method in _REQUEST_METHODS),
# A request handler's parameters are segments of its URL; a WebSocket handler also
# receives the messages, raw payloads a ``json.loads`` may decode.
EntryPoint(_sym("tornado.web.RequestHandler"), "http", TEXT_KINDS),
EntryPoint(_sym("tornado.websocket.WebSocketHandler"), "http"),
*(Source(_sym(f"{handler}.{method}"), "http", TEXT_KINDS) for handler in _HANDLERS for method in _TEXT_METHODS),
*(Source(_sym(f"{handler}.request"), "http") for handler in _HANDLERS),
*(Sink(_sym(f"{handler}.write"), TaintKind.HTML) for handler in _HANDLERS),
*(Sink(_sym(f"{handler}.finish"), TaintKind.HTML) for handler in _HANDLERS),
*(Sink(_sym(f"{handler}.redirect"), TaintKind.REDIRECT, _TARGET_ONLY) for handler in _HANDLERS),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""NoSQL injection: attacker input that may hold a structure reaching a NOSQL sink, such
as a MongoDB filter, where a JSON body giving ``{"$ne": null}`` matches any value. Text
input carries no ``NOSQL``: a string cannot hold an operator. The engine ships no NOSQL
sink; model plugins declare them."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.findings import Severity
from coretrace_python.plugins import TaintDetector
from coretrace_python.taint import TaintKind


class NoSqlInjectionPlugin(TaintDetector):
name: ClassVar[str] = "nosql-injection"
rule_id: ClassVar[str] = "nosql-injection"
kind: ClassVar[TaintKind] = TaintKind.NOSQL
severity: ClassVar[Severity] = Severity.HIGH
title: ClassVar[str] = "NoSQL injection"
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "nosql-injection"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["taint.flows", "findings.refutation"]
provides = ["vulnerability.nosql-injection"]

[entrypoint]
module = "nosql_injection"
class = "NoSqlInjectionPlugin"
8 changes: 7 additions & 1 deletion src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
from coretrace_python.semantic import SEMANTIC_ANALYSES
from coretrace_python.semantic.imports import ImportAnalysis, ImportResolutionError, ImportTable
from coretrace_python.semantic.scopes import ScopeAnalysis, ScopeError
from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId
from coretrace_python.semantic.symbols import MembersAnalysis, SymbolAnalysis, SymbolId
from coretrace_python.source import SourceFile, SourceId, SourceManager, SourceSpan, decode_text
from coretrace_python.taint import (
EntryPoint,
Expand Down Expand Up @@ -257,6 +257,7 @@ def build_manager(
manager = _register_all(module)
table = (models or SecurityModelRegistry()).freeze()
manager.provide(SecurityModelAnalysis, table)
manager.provide(MembersAnalysis, table.members_by_class())
manager.provide(ClearingAnalysis, table.clearing())
manager.provide(ProjectSummaries, SummaryIndex())
manager.provide(RegisteredRoutes, _routes_of(manager))
Expand Down Expand Up @@ -311,6 +312,7 @@ def analyze_file(source: SourceFile, plugin_roots: Sequence[Path]) -> FileAnalys
registry = load_plugins(plugin_roots, manager)
table = plugin_models(loaded.plugin for loaded in registry)
manager.provide(SecurityModelAnalysis, table)
manager.provide(MembersAnalysis, table.members_by_class())
manager.provide(ClearingAnalysis, table.clearing())
manager.provide(ProjectSummaries, SummaryIndex())
manager.provide(RegisteredRoutes, _routes_of(manager))
Expand Down Expand Up @@ -402,8 +404,10 @@ def analyze_project(
advisories, origins = _merge_advisories(_contributions(registry, plugins, root, file_advisories))
affected = affected_symbols(dependencies, advisories)
models = plugin_models(all_plugins, root).extended(*advisory_sinks(affected))
members = models.members_by_class()
for manager in managers.values():
manager.provide(SecurityModelAnalysis, models)
manager.provide(MembersAnalysis, members)
manager.provide(DependencyAnalysis, dependencies)

imports: dict[str, ImportTable] = {}
Expand Down Expand Up @@ -698,8 +702,10 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
models = plugin_models(all_plugins, batch.root).extended(*advisory_sinks(affected))
routes = _decode_routes(batch.routes)
escaped = frozenset(batch.escaped)
members = models.members_by_class()
for manager in managers.values():
manager.provide(SecurityModelAnalysis, models)
manager.provide(MembersAnalysis, members)
manager.provide(DependencyAnalysis, dependencies)
manager.provide(RegisteredRoutes, routes)
manager.provide(EscapedTemplates, escaped)
Expand Down
Loading
Loading