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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Precision

- The text attributes and URL parameters of a request object carry no `NOSQL`. Django, REST framework, aiohttp and tornado views read a request tainted as a whole, so `request.GET["name"]` and a URL parameter were `nosql-injection` like the body. Now `GET`, `POST`, `COOKIES`, `META`, `headers`, `query_params`, aiohttp's `query` and `match_info`, tornado's `arguments` and the other text attributes are text, and so are the URL parameters a view receives after the request. The body, the files, REST framework's `data` and aiohttp's `json()` keep the kinds of the request. The other kinds are unchanged, and so is the regression corpus. A helper function the request is passed to still reads it as a whole (#162).

### Plugins

- A `RequestObject(symbol, text=())` model says that the inputs from an entry point, a route registrar, a typed parameter or a source are request objects, and which of their attributes are text (#162).

## 0.12.0 (2026-09-26)

### Detection
Expand Down
1 change: 1 addition & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ class BottleModels(ModelPlugin):
| `SafeArgument(symbol, argument, values, position=None, kinds=ALL)` | A call to the sink `symbol` whose `argument` (by keyword, or at `position`) denotes one of `values` is not a sink for `kinds`, such as `yaml.load` with `Loader=yaml.SafeLoader`. Values are written as the analyzer records arguments: a symbol by its canonical name, a constant as Python writes it (`True`). Only a value given explicitly makes the call safe. |
| `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. |
| `RequestObject(symbol, text=())` | The inputs from `symbol`, an entry point, a route registrar, a typed parameter or a source, are request objects. A view receives the request first (after `self`), then URL parameters, which are text. Reading one of the request's `text` attributes gives text, as Django's `GET` or aiohttp's `query`; its body and files keep the request's kinds. An attribute read counts only when every source of the value is such a request. |
| `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. |

Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +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. |
| `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. A Django, REST framework, aiohttp or tornado view reads a request object: its text attributes (`GET`, `POST`, `headers`, `query_params`, `query`, `match_info`, `arguments`, …) and the URL parameters after it are text, its body, files and `data` are not. Limits: a helper function the request is passed to reads it as a whole; 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
32 changes: 22 additions & 10 deletions src/coretrace_python/bundled/models/aiohttp/aiohttp_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from coretrace_python.taint import (
EntryPoint,
Model,
RequestObject,
RouteRegistrar,
Sink,
Source,
Expand All @@ -22,6 +23,23 @@
_METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
_REQUEST_CLASSES = ("aiohttp.web.Request", "aiohttp.web_request.Request")
_ROUTERS = ("aiohttp.web.Application.router", "aiohttp.web.UrlDispatcher")
# ``@routes.get('/')`` on a ``RouteTableDef``.
_ROUTE_TABLES = tuple(f"aiohttp.web.RouteTableDef.{method}" for method in (*_METHODS, "route", "view"))
# ``app.router.add_route('GET', '/', handler)``, ``app.router.add_get('/', handler)`` and
# ``app.add_routes([web.get('/', handler)])``, each with the argument naming the handler.
_ROUTE_CALLS = (
*((f"{router}.add_route", 2) for router in _ROUTERS),
*((f"{router}.add_{method}", 1) for router in _ROUTERS for method in (*_METHODS, "view")),
("aiohttp.web.route", 2),
*((f"aiohttp.web.{method}", 1) for method in (*_METHODS, "view")),
)
# What a request gives as text; its body (``json()``, ``text()``, ``read()``) may hold a
# structure.
_REQUEST_TEXT = (
"query", "rel_url", "url", "path", "raw_path", "path_qs", "query_string", "match_info", "headers",
"raw_headers", "cookies", "method", "host", "scheme", "remote", "forwarded", "content_type", "charset",
"if_modified_since", "post",
)
_REDIRECTS = ("HTTPFound", "HTTPMovedPermanently", "HTTPSeeOther", "HTTPTemporaryRedirect", "HTTPPermanentRedirect")
_CLIENT_FUNCTIONS = (
*(f"aiohttp.ClientSession.{method}" for method in (*_METHODS, "request")),
Expand All @@ -40,18 +58,12 @@ class AiohttpModels(ModelPlugin):
# The whole request is attacker-controlled: its query, match info, headers,
# cookies and body all come from the client.
*(TypedParameter(_sym(cls), "http") for cls in _REQUEST_CLASSES),
# ``@routes.get('/')`` on a ``RouteTableDef``.
*(EntryPoint(_sym(f"aiohttp.web.RouteTableDef.{method}"), "http") for method in (*_METHODS, "route", "view")),
# ``app.router.add_route('GET', '/', handler)`` and ``app.router.add_get('/', handler)``.
*(RouteRegistrar(_sym(f"{router}.add_route"), 2, "http", keyword="handler") for router in _ROUTERS),
*(EntryPoint(_sym(table), "http") for table in _ROUTE_TABLES),
*(RouteRegistrar(_sym(call), argument, "http", keyword="handler") for call, argument in _ROUTE_CALLS),
*(
RouteRegistrar(_sym(f"{router}.add_{method}"), 1, "http", keyword="handler")
for router in _ROUTERS
for method in (*_METHODS, "view")
RequestObject(_sym(symbol), _REQUEST_TEXT)
for symbol in (*_REQUEST_CLASSES, *_ROUTE_TABLES, *(call for call, _ in _ROUTE_CALLS))
),
# ``app.add_routes([web.get('/', handler)])``: the route definitions name the handler.
RouteRegistrar(_sym("aiohttp.web.route"), 2, "http", keyword="handler"),
*(RouteRegistrar(_sym(f"aiohttp.web.{method}"), 1, "http", keyword="handler") for method in (*_METHODS, "view")),
Sink(_sym("aiohttp.web.Response"), TaintKind.HTML),
Sink(_sym("aiohttp.web.FileResponse"), TaintKind.PATH),
*(Sink(_sym(f"aiohttp.web.{exception}"), TaintKind.REDIRECT) for exception in _REDIRECTS),
Expand Down
19 changes: 19 additions & 0 deletions src/coretrace_python/bundled/models/django/django_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
AuthorizationGuard,
EntryPoint,
Model,
RequestObject,
RouteRegistrar,
Sanitizer,
Sink,
Expand Down Expand Up @@ -88,6 +89,23 @@
)


# What a request object gives as text: the query string, form fields, headers, cookies
# and path. Its body, its files and REST framework's parsed ``data`` may hold a structure.
_REQUEST_TEXT = (
"GET", "POST", "COOKIES", "META", "headers", "path", "path_info", "method", "scheme", "encoding",
"content_type", "content_params", "resolver_match", "get_full_path", "get_full_path_info",
"build_absolute_uri", "get_host", "get_port", "get_signed_cookie", "query_params",
)
# Every input a request object comes from: an annotated parameter, a class-based view's
# method or ``self.request``, a decorated view, a view registered in a URL configuration.
_REQUEST_INPUTS = (
*_REQUEST_CLASSES,
*_VIEW_BASES,
*(f"{base}.request" for base in _VIEW_BASES),
*_VIEW_DECORATORS,
*_ROUTE_REGISTRARS,
)

_TARGET_ONLY = ((TaintKind.REDIRECT, (0,)),)


Expand All @@ -103,6 +121,7 @@ class DjangoModels(ModelPlugin):
# ``self.request`` in a class-based view: the attribute is inherited from the base.
*(Source(_sym(f"{base}.request"), "http") for base in _VIEW_BASES),
*(EntryPoint(_sym(decorator), "http") for decorator in _VIEW_DECORATORS),
*(RequestObject(_sym(symbol), _REQUEST_TEXT) for symbol in _REQUEST_INPUTS),
Sink(_sym("django.db.connection.cursor.execute"), TaintKind.SQL | TaintKind.CREDENTIAL, ((TaintKind.SQL, (0,)),)),
Sink(_sym("django.db.connection.cursor.executemany"), TaintKind.SQL | TaintKind.CREDENTIAL, ((TaintKind.SQL, (0,)),)),
Sink(_sym("django.db.models.expressions.RawSQL"), TaintKind.SQL | TaintKind.CREDENTIAL),
Expand Down
17 changes: 16 additions & 1 deletion src/coretrace_python/bundled/models/tornado/tornado_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,28 @@

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

_HANDLERS = ("tornado.web.RequestHandler", "tornado.websocket.WebSocketHandler")
# 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", "path_args", "path_kwargs",
)
# What ``self.request`` gives as text; its body and files may hold a structure.
_REQUEST_TEXT = (
"arguments", "query_arguments", "body_arguments", "query", "uri", "path", "headers", "cookies",
"method", "host", "host_name", "remote_ip", "protocol", "version", "full_url",
)
_CLIENTS = ("tornado.httpclient.AsyncHTTPClient.fetch", "tornado.httpclient.HTTPClient.fetch")
_TARGET_ONLY = ((TaintKind.REDIRECT, (0,)),)

Expand All @@ -33,6 +47,7 @@ class TornadoModels(ModelPlugin):
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),
*(RequestObject(_sym(f"{handler}.request"), _REQUEST_TEXT) 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
2 changes: 2 additions & 0 deletions src/coretrace_python/taint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ModelError,
ModelTable,
NamedParameter,
RequestObject,
RouteRegistrar,
SafeArgument,
Sanitizer,
Expand Down Expand Up @@ -45,6 +46,7 @@
"ModelTable",
"NamedParameter",
"RegisteredRoutes",
"RequestObject",
"RouteRegistrar",
"Routes",
"SafeArgument",
Expand Down
51 changes: 39 additions & 12 deletions src/coretrace_python/taint/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
from coretrace_python.semantic.symbols import SymbolAnalysis, SymbolId, SymbolTable
from coretrace_python.source import SourceSpan
from coretrace_python.taint.models import (
TEXT_KINDS,
EntryPoint,
ModelTable,
SecurityModelAnalysis,
Expand All @@ -84,6 +85,9 @@
from coretrace_python.taint.routes import RegisteredRoutes, Routes
from coretrace_python.taint.templates import EscapedTemplates

# What only a structure carries, and text does not.
_STRUCTURED = TaintKind.ALL & ~TEXT_KINDS


@dataclass(frozen=True)
class Taint:
Expand Down Expand Up @@ -274,19 +278,42 @@ def instruction(self, instruction: Instruction, state: Mapping[Key, Taint]) -> T
taint = Taint.none()
symbol = self.symbols.get(instruction.result) if instruction.result else None
if symbol is not None:
source = self.models.source_covering(symbol)
if source is not None:
taint = Taint(source.kinds, frozenset({source}))
taint = self.covered(symbol)
if isinstance(instruction, Symbol | Compare):
return taint
for operand in instruction.operands():
taint = taint.join(state.get(operand, Taint.none()))
operand_taint = state.get(operand, Taint.none())
if isinstance(instruction, GetAttr):
operand_taint = self.attribute(operand_taint, instruction.attribute)
taint = taint.join(operand_taint)
if isinstance(instruction, BuildList | BuildTuple | BuildDict | BuildSet):
# ``[*xs]`` and ``{**d}`` copy the contents of what they unpack.
for unpacked in instruction.unpacked:
taint = taint.join(self.deep(unpacked, state))
return taint

def covered(self, symbol: SymbolId) -> Taint:
"""The taint of the source covering ``symbol``, if any. Below a request source, as
``self.request.GET`` is, it is the taint of the request attribute read."""

source = self.models.source_covering(symbol)
if source is None:
return Taint.none()
taint = Taint(source.kinds, frozenset({source}))
if symbol == source.symbol:
return taint
read = symbol.canonical_name.removeprefix(f"{source.symbol.canonical_name}.").split(".")[0]
return self.attribute(taint, read)

def attribute(self, taint: Taint, name: str) -> Taint:
"""What attribute ``name`` of a value carrying ``taint`` carries: text when every
source of the value is a request object listing ``name`` among its text attributes."""

requests = [self.models.request_object(source.symbol) for source in taint.sources]
if taint and all(request is not None and name in request.text for request in requests):
return taint.without(_STRUCTURED)
return taint

def call(self, call: Call, state: dict[Key, Taint], flows: list[TaintFlow]) -> Taint:
arguments = tuple(self.deep(a, state) for a in call.arguments)
keywords = Taint.none()
Expand Down Expand Up @@ -420,11 +447,7 @@ def external(
if cleared:
return everything.without(TaintKind(cleared))
# A method on a tainted object returns tainted data (``request.args.get``).
everything = everything.join(state.get(call.callee, Taint.none()))
source = self.models.source_covering(symbol)
if source is not None:
return everything.join(Taint(source.kinds, frozenset({source})))
return everything
return everything.join(state.get(call.callee, Taint.none())).join(self.covered(symbol))

def guarded(self, sink: Sink | None, arguments: Arguments) -> Sink | None:
"""The sink as this call makes it: an argument declared safe (``yaml.load`` with
Expand Down Expand Up @@ -777,7 +800,8 @@ def parameter_sources(
entry point (``self`` excepted for a method), including one registered elsewhere
(``routes``), and every parameter annotated with a typed-parameter symbol. An entry
point's parameter annotated with scalars is text: FastAPI validates it before the
handler runs, and a scalar cannot hold a query operator."""
handler runs, and a scalar cannot hold a query operator. So are the URL parameters
a view receives after a request object."""

owner = _owner(module, function)
sources: dict[int, Source] = {}
Expand All @@ -786,10 +810,13 @@ def parameter_sources(
entry = function_entry_point(function, module, models, scopes, symbols, instances, routes)
if entry is not None:
first = 1 if owner is not None else 0
request = models.request_object(entry.symbol)
for index in range(first, len(function.parameters)):
annotation = function.parameters[index].annotation
scalar = annotation is not None and _scalar(annotation, symbols, enclosing)
kinds = entry.kinds & ~TaintKind.NOSQL if scalar else entry.kinds
text = (request is not None and index > first) or (
annotation is not None and _scalar(annotation, symbols, enclosing)
)
kinds = entry.kinds & ~_STRUCTURED if text else entry.kinds
sources[index] = Source(entry.symbol, entry.label, kinds)
for index, parameter in enumerate(function.parameters):
if parameter.annotation is None:
Expand Down
Loading
Loading