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
8 changes: 4 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ report's per-file detail tell "no findings" from "nothing analysed".

Taint follows values between functions and across files through function summaries,
into objects (containers, attributes, instances of the project's own classes) and through
closures. Flask, FastAPI and Django route handlers, class-based views and registered
closures. Flask, FastAPI, Django and aiohttp route handlers, class-based views and registered
URL patterns receive HTTP input; click and Typer commands receive `argv` input; `input()`,
`sys.argv`, environment variables, the output of local processes and the responses of
HTTP clients are further sources. The first three are operator-controlled: a command-line
Expand Down Expand Up @@ -309,10 +309,10 @@ real 45 000-line project is exercised on every change.

## Plugins

The package ships 26 plugins, loaded by default. Security models for the standard library
The package ships 28 plugins, loaded by default. Security models for the standard library
and the supported frameworks: `python-stdlib-models`, `flask-models`, `fastapi-models`,
`django-models`, `sqlalchemy-models`, `http-client-models`, `credential-models` and
`cli-models`. Taint detectors: `command-injection`, `sql-injection`, `path-traversal`,
`django-models`, `aiohttp-models`, `sqlalchemy-models`, `db-driver-models` (aiopg,
asyncpg, psycopg2, PyMySQL), `http-client-models`, `credential-models` and `cli-models`. Taint detectors: `command-injection`, `sql-injection`, `path-traversal`,
`ssrf`, `xss`, `insecure-deserialization`, `open-redirect` and `plaintext-credentials`.
Dangerous API detectors: `dangerous-eval`, `weak-crypto`, `flask-debug` and
`missing-timeout`. Secret scanners: `hardcoded-secrets` for Python sources and
Expand Down
61 changes: 61 additions & 0 deletions src/coretrace_python/bundled/models/aiohttp/aiohttp_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""aiohttp security models: handlers receive HTTP input whether they take an annotated
``web.Request``, are registered on the router or decorated by a ``RouteTableDef``;
responses, file responses and redirect exceptions are sinks; client sessions are SSRF
sinks whose responses are untrusted."""

from __future__ import annotations

from typing import ClassVar

from coretrace_python.plugins import ModelPlugin
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.taint import (
EntryPoint,
Model,
RouteRegistrar,
Sink,
Source,
TaintKind,
TypedParameter,
)

_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")
_REDIRECTS = ("HTTPFound", "HTTPMovedPermanently", "HTTPSeeOther", "HTTPTemporaryRedirect", "HTTPPermanentRedirect")
_CLIENT_FUNCTIONS = (
*(f"aiohttp.ClientSession.{method}" for method in (*_METHODS, "request")),
*(f"aiohttp.client.ClientSession.{method}" for method in (*_METHODS, "request")),
"aiohttp.request",
)


def _sym(path: str) -> SymbolId:
return SymbolId(f"python.{path}")


class AiohttpModels(ModelPlugin):
name: ClassVar[str] = "aiohttp-models"
models: ClassVar[tuple[Model, ...]] = (
# 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),
*(
RouteRegistrar(_sym(f"{router}.add_{method}"), 1, "http", keyword="handler")
for router in _ROUTERS
for method in (*_METHODS, "view")
),
# ``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),
*(Sink(_sym(f"aiohttp.web_exceptions.{exception}"), TaintKind.REDIRECT) for exception in _REDIRECTS),
*(Sink(_sym(function), TaintKind.SSRF) for function in _CLIENT_FUNCTIONS),
*(Source(_sym(function), "http-response") for function in _CLIENT_FUNCTIONS),
)
9 changes: 9 additions & 0 deletions src/coretrace_python/bundled/models/aiohttp/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "aiohttp-models"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = []
provides = ["model.http-sources", "model.aiohttp-routes", "model.ssrf-sinks"]

[entrypoint]
module = "aiohttp_models"
class = "AiohttpModels"
71 changes: 71 additions & 0 deletions src/coretrace_python/bundled/models/db_drivers/db_driver_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Database driver models: the statement argument of aiopg, asyncpg, psycopg2 and PyMySQL
query methods is a SQL sink; the parameter tuple or mapping is not a statement."""

from __future__ import annotations

from typing import ClassVar

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

_STATEMENT_ONLY = ((TaintKind.SQL, (0,)),)
_CURSOR_METHODS = ("execute", "executemany", "mogrify")
_ASYNCPG_METHODS = ("execute", "executemany", "fetch", "fetchrow", "fetchval", "cursor")

# Every way a cursor is reached: through a connection object, an annotated class or a
# pool; the symbol derivation follows call chains, so ``connect().cursor().execute`` is
# ``connect.cursor.execute``.
_CURSORS = (
"aiopg.connect.cursor",
"aiopg.Connection.cursor",
"aiopg.connection.Connection.cursor",
"aiopg.Cursor",
"aiopg.cursor.Cursor",
"aiopg.create_pool.acquire.cursor",
"aiopg.Pool.acquire.cursor",
"aiopg.pool.Pool.acquire.cursor",
"aiopg.create_pool.cursor",
"aiopg.Pool.cursor",
"psycopg2.connect.cursor",
"psycopg2.extensions.connection.cursor",
"psycopg2.extensions.cursor",
"psycopg2.extras.DictCursor",
"psycopg2.extras.RealDictCursor",
"pymysql.connect.cursor",
"pymysql.connections.Connection.cursor",
"pymysql.Connection.cursor",
"pymysql.cursors.Cursor",
"pymysql.cursors.DictCursor",
)
_ASYNCPG_CONNECTIONS = (
"asyncpg.connect",
"asyncpg.Connection",
"asyncpg.connection.Connection",
"asyncpg.create_pool",
"asyncpg.Pool",
"asyncpg.pool.Pool",
"asyncpg.create_pool.acquire",
"asyncpg.Pool.acquire",
"asyncpg.pool.Pool.acquire",
)


def _sym(path: str) -> SymbolId:
return SymbolId(f"python.{path}")


class DbDriverModels(ModelPlugin):
name: ClassVar[str] = "db-driver-models"
models: ClassVar[tuple[Model, ...]] = (
*(
Sink(_sym(f"{cursor}.{method}"), TaintKind.SQL, _STATEMENT_ONLY)
for cursor in _CURSORS
for method in _CURSOR_METHODS
),
*(
Sink(_sym(f"{connection}.{method}"), TaintKind.SQL, _STATEMENT_ONLY)
for connection in _ASYNCPG_CONNECTIONS
for method in _ASYNCPG_METHODS
),
)
9 changes: 9 additions & 0 deletions src/coretrace_python/bundled/models/db_drivers/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "db-driver-models"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = []
provides = ["model.sql-sinks"]

[entrypoint]
module = "db_driver_models"
class = "DbDriverModels"
4 changes: 4 additions & 0 deletions src/coretrace_python/interprocedural/callgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from coretrace_python.hir import nodes
from coretrace_python.ir.lowering import LoweringError, analyzable_functions, qualified_name
from coretrace_python.ir.model import (
Await,
Call,
FunctionIR,
GetAttr,
Expand Down Expand Up @@ -139,6 +140,9 @@ def derive_symbols(
instruction.callee if isinstance(instruction, Call) else instruction.context
)
symbol = symbols.get(origin)
elif isinstance(instruction, Await) and instruction.value in symbols:
# ``await asyncpg.connect()`` denotes what the awaited call denotes.
symbol = symbols[instruction.value]
if symbol is not None:
symbols[instruction.result] = symbol
changed = True
Expand Down
57 changes: 54 additions & 3 deletions src/coretrace_python/taint/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,8 @@ def registered_routes(
s.name for s in module.body if isinstance(s, nodes.Function | nodes.Class)
}
found: dict[SymbolId, EntryPoint] = {}
for call in _calls(module.body):
callee = symbols.resolve_expression(scope, call.callee)
registrar = models.route_registrar(callee) if callee is not None else None
for call, callee in _registration_calls(module, scopes, symbols):
registrar = models.route_registrar(callee)
if registrar is None:
continue
handler: nodes.Expression | None = None
Expand Down Expand Up @@ -83,6 +82,58 @@ def _handler_symbol(
return None


def _registration_calls(
module: nodes.Module, scopes: ScopeTable, symbols: SymbolTable
) -> Iterator[tuple[nodes.Call, SymbolId]]:
"""Every call of the module with a resolvable callee: at module level, and inside
functions such as ``def setup_routes(app: Application)``, where a call on an
annotated parameter denotes the class's method (``app.router.add_route``)."""

module_scope = scopes.module_scope.id
for call in _calls(module.body):
callee = symbols.resolve_expression(module_scope, call.callee)
if callee is not None:
yield call, callee
for function in _functions(module.body):
scope = scopes.scope_for(function)
enclosing = scope.parent if scope.parent is not None else scope.id
annotated: dict[str, SymbolId] = {}
for parameter in function.parameters:
if parameter.annotation is not None:
symbol = symbols.resolve_expression(enclosing, parameter.annotation)
if symbol is not None:
annotated[parameter.name] = symbol
for call in _calls(function.body):
callee = symbols.resolve_expression(scope.id, call.callee)
if callee is None:
callee = _through_annotation(call.callee, annotated)
if callee is not None:
yield call, callee


def _through_annotation(expression: nodes.Expression, annotated: Mapping[str, SymbolId]) -> SymbolId | None:
"""``app.router.add_route`` with ``app: Application`` is ``Application.router.add_route``."""

names: list[str] = []
while isinstance(expression, nodes.Attribute):
names.append(expression.name)
expression = expression.value
if not isinstance(expression, nodes.Name) or expression.identifier not in annotated:
return None
symbol = annotated[expression.identifier]
for name in reversed(names):
symbol = symbol.attribute(name)
return symbol


def _functions(body: tuple[nodes.Statement, ...]) -> Iterator[nodes.Function]:
for statement in body:
if isinstance(statement, nodes.Function):
yield statement
elif isinstance(statement, nodes.Class):
yield from (member for member in statement.body if isinstance(member, nodes.Function))


def _calls(body: tuple[nodes.Statement, ...]) -> Iterator[nodes.Call]:
for statement in body:
if isinstance(statement, nodes.Function | nodes.Class):
Expand Down
6 changes: 6 additions & 0 deletions tests/regression/expected/dvpwa.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@
"line": 41,
"rule": "weak-crypto",
"function": "check_password"
},
{
"file": "sqli/views.py",
"line": 57,
"rule": "sql-injection",
"function": "students"
}
]
}
Loading
Loading