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
9 changes: 5 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ 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, Django and aiohttp route handlers, class-based views and registered
URL patterns receive HTTP input; click and Typer commands receive `argv` input; `input()`,
closures. Flask, FastAPI, Django, aiohttp, Tornado and Bottle route handlers, class-based views
and registered URL patterns receive HTTP input, and a method a handler class inherits
from its framework base, `self.get_argument` or `self.request`, denotes the base's; 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
tool opens the paths it is given, a command or a path built from the environment is the
Expand Down Expand Up @@ -309,9 +310,9 @@ real 45 000-line project is exercised on every change.

## Plugins

The package ships 28 plugins, loaded by default. Security models for the standard library
The package ships 30 plugins, loaded by default. Security models for the standard library
and the supported frameworks: `python-stdlib-models`, `flask-models`, `fastapi-models`,
`django-models`, `aiohttp-models`, `sqlalchemy-models`, `db-driver-models` (aiopg,
`django-models`, `aiohttp-models`, `tornado-models`, `bottle-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
Expand Down
39 changes: 39 additions & 0 deletions src/coretrace_python/bundled/models/bottle/bottle_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Bottle security models: ``@route``, ``@get`` and the methods of a ``Bottle`` application
are entry points, ``bottle.request`` is the HTTP source, ``redirect``, ``static_file``,
``template`` and ``HTTPResponse`` are sinks, ``html_escape`` a sanitizer."""

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, Sanitizer, Sink, Source, TaintKind

_METHODS = ("route", "get", "post", "put", "delete", "patch")
_REQUEST_ATTRIBUTES = (
"query", "forms", "params", "json", "body", "headers", "cookies", "GET", "POST", "files",
"url", "path", "fullpath", "query_string", "get_cookie", "get_header",
)
_FIRST_ONLY = lambda kind: ((kind, (0,)),)


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


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),
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)),
# ``template(source_or_name, **variables)``: a tainted template is injection, the
# variables are escaped by the engine.
Sink(_sym("bottle.template"), TaintKind.HTML, _FIRST_ONLY(TaintKind.HTML)),
Sink(_sym("bottle.HTTPResponse"), TaintKind.HTML),
Sanitizer(_sym("bottle.html_escape"), TaintKind.HTML),
)
9 changes: 9 additions & 0 deletions src/coretrace_python/bundled/models/bottle/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "bottle-models"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = []
provides = ["model.http-sources", "model.bottle-routes"]

[entrypoint]
module = "bottle_models"
class = "BottleModels"
3 changes: 3 additions & 0 deletions src/coretrace_python/bundled/models/django/django_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
RouteRegistrar,
Sanitizer,
Sink,
Source,
SuffixSink,
TaintKind,
TypedParameter,
Expand Down Expand Up @@ -98,6 +99,8 @@ class DjangoModels(ModelPlugin):
models: ClassVar[tuple[Model, ...]] = (
*(TypedParameter(_sym(cls), "http") for cls in _REQUEST_CLASSES),
*(EntryPoint(_sym(base), "http") for base in _VIEW_BASES),
# ``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),
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,)),)),
Expand Down
9 changes: 9 additions & 0 deletions src/coretrace_python/bundled/models/tornado/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "tornado-models"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = []
provides = ["model.http-sources", "model.tornado-handlers", "model.ssrf-sinks"]

[entrypoint]
module = "tornado_models"
class = "TornadoModels"
37 changes: 37 additions & 0 deletions src/coretrace_python/bundled/models/tornado/tornado_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Tornado security models: ``RequestHandler`` subclasses are entry points whose inherited
request methods are HTTP sources and whose ``write``, ``finish`` and ``redirect`` are
sinks; the HTTP clients are SSRF sinks with untrusted responses."""

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, Sanitizer, Sink, Source, TaintKind

_HANDLERS = ("tornado.web.RequestHandler", "tornado.websocket.WebSocketHandler")
_REQUEST_METHODS = (
"get_argument", "get_arguments", "get_query_argument", "get_query_arguments",
"get_body_argument", "get_body_arguments", "get_cookie", "request", "path_args", "path_kwargs",
)
_CLIENTS = ("tornado.httpclient.AsyncHTTPClient.fetch", "tornado.httpclient.HTTPClient.fetch")
_TARGET_ONLY = ((TaintKind.REDIRECT, (0,)),)


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


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),
*(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),
*(Sink(_sym(client), TaintKind.SSRF) for client in _CLIENTS),
*(Source(_sym(client), "http-response") for client in _CLIENTS),
Sanitizer(_sym("tornado.escape.xhtml_escape"), TaintKind.HTML),
)
4 changes: 3 additions & 1 deletion src/coretrace_python/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.source import SourceId, SourceSpan

CACHE_FORMAT = 3
CACHE_FORMAT = 4


@dataclass(frozen=True)
Expand Down Expand Up @@ -252,6 +252,7 @@ def _encode_summary(summary: FunctionSummary) -> dict[str, Any]:
{"name": w.name, "dependencies": sorted(w.dependencies), "externals": sorted(str(s) for s in w.externals)}
for w in summary.nonlocal_writes
],
"static": summary.static,
}


Expand Down Expand Up @@ -279,6 +280,7 @@ def _decode_summary(data: Mapping[str, Any]) -> FunctionSummary:
)
for w in data["nonlocal_writes"]
),
bool(data["static"]),
)


Expand Down
61 changes: 52 additions & 9 deletions src/coretrace_python/interprocedural/callgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,23 +158,43 @@ def resolve_targets(
classes: Mapping[str, frozenset[str]] | None = None,
owner: str | None = None,
typed: Mapping[Value, str] | None = None,
bases: Mapping[str, SymbolId] | None = None,
) -> tuple[dict[Value, Target], dict[Value, SymbolId]]:
"""Map every callee value of ``function`` to its target, and every symbol value.
``nested`` names the functions defined inside this one (``outer.inner``);
``classes`` maps the module's classes to their methods, ``owner`` is the class of a
method and ``typed`` the parameters annotated with a module class. ``App(x)`` is a
call to ``App.__init__``, ``app.run()`` and ``self.run()`` calls to ``App.run``."""
call to ``App.__init__``, ``app.run()`` and ``self.run()`` calls to ``App.run``.
``bases`` maps a module class to the symbol of its base: an attribute the class does
not define is inherited, so ``self.get_argument`` in a ``RequestHandler`` subclass
denotes ``tornado.web.RequestHandler.get_argument``."""

module = scopes.module_scope
symbols = derive_symbols(function, parameters)
targets: dict[Value, Target] = {
value: ExternalSymbol(symbol) for value, symbol in symbols.items()
}
classes = classes or {}
instance_of: dict[Value, str] = dict(typed or {})
if owner is not None and function.parameters:
instance_of[function.parameters[0]] = owner
defs = {i.result: i for block in function.blocks for i in block.instructions if i.result}
for block in function.blocks:
for instruction in block.instructions:
if isinstance(instruction, Call):
callee = defs.get(instruction.callee)
if isinstance(callee, Global) and callee.name in classes:
instance_of[instruction.result] = callee.name
inherited: dict[Value, SymbolId] = dict(parameters or {})
for block in function.blocks:
for instruction in block.instructions:
if isinstance(instruction, GetAttr) and instruction.object in instance_of:
if instruction.object in inherited:
continue # an annotated parameter denotes its own class
class_name = instance_of[instruction.object]
base = (bases or {}).get(class_name)
if base is not None and instruction.attribute not in classes.get(class_name, ()):
inherited[instruction.result] = base.attribute(instruction.attribute)
symbols = derive_symbols(function, inherited)
targets: dict[Value, Target] = {
value: ExternalSymbol(symbol) for value, symbol in symbols.items()
}
for block in function.blocks:
for instruction in block.instructions:
if isinstance(instruction, MakeFunction):
Expand All @@ -189,17 +209,29 @@ def resolve_targets(
targets[instruction.result] = KnownFunction(instruction.name)
elif binding.kind is BindingKind.CLASS and "__init__" in classes.get(instruction.name, ()):
targets[instruction.result] = KnownFunction(f"{instruction.name}.__init__")
elif isinstance(instruction, Call):
callee = defs.get(instruction.callee)
if isinstance(callee, Global) and callee.name in classes:
instance_of[instruction.result] = callee.name
elif isinstance(instruction, GetAttr) and instruction.object in instance_of:
method = f"{instance_of[instruction.object]}.{instruction.attribute}"
if method in nested:
targets[instruction.result] = KnownFunction(method)
elif isinstance(instruction, GetAttr):
# ``Student.create(...)``: a static or class method called on the class.
origin = defs.get(instruction.object)
if isinstance(origin, Global) and origin.name in classes:
method = f"{origin.name}.{instruction.attribute}"
if method in nested:
targets[instruction.result] = KnownFunction(method)
return targets, symbols


def _is_staticmethod(function: nodes.Function) -> bool:
for decorator in function.decorators:
if isinstance(decorator, nodes.Name) and decorator.identifier == "staticmethod":
return True
if isinstance(decorator, nodes.Attribute) and decorator.name == "staticmethod":
return True
return False


def _annotated(
function: nodes.Function, ssa: FunctionIR, scopes: ScopeTable, table: SymbolTable
) -> dict[Value, SymbolId]:
Expand Down Expand Up @@ -257,6 +289,14 @@ def compute(cls, ctx: AnalysisContext) -> CallGraph:
for statement in ctx.module.body
if isinstance(statement, nodes.Class)
}
bases: dict[str, SymbolId] = {}
for statement in ctx.module.body:
if isinstance(statement, nodes.Class):
for base_expression in statement.bases:
symbol = table.resolve_expression(scopes.module_scope.id, base_expression)
if symbol is not None:
bases[statement.name] = symbol
break
sites: dict[str, tuple[CallSite, ...]] = {}
symbols: dict[str, Mapping[Value, SymbolId]] = {}
unsupported: set[str] = set()
Expand All @@ -268,6 +308,8 @@ def compute(cls, ctx: AnalysisContext) -> CallGraph:
sites[name] = ()
continue
owner = name.rsplit(".", 1)[0] if "." in name and name.rsplit(".", 1)[0] in classes else None
if owner is not None and _is_staticmethod(function):
owner = None # a static method has no receiver
targets, symbols[name] = resolve_targets(
ssa,
scopes,
Expand All @@ -277,6 +319,7 @@ def compute(cls, ctx: AnalysisContext) -> CallGraph:
classes,
owner,
_typed_with_module_classes(function, ssa, classes),
bases,
)
found: list[CallSite] = []
for block in ssa.blocks:
Expand Down
18 changes: 13 additions & 5 deletions src/coretrace_python/interprocedural/summaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
ExternalSymbol,
KnownFunction,
UnknownTarget,
_is_staticmethod,
)
from coretrace_python.ir.model import (
BasicBlock,
Expand Down Expand Up @@ -125,6 +126,8 @@ class FunctionSummary:
mutations: tuple[Mutation, ...] = ()
side_effects: frozenset[str] = frozenset()
nonlocal_writes: tuple[NonlocalWrite, ...] = ()
# A ``@staticmethod``: ``Class.method(...)`` passes no receiver.
static: bool = False


class SummaryIndex:
Expand Down Expand Up @@ -322,7 +325,7 @@ def call(self, call: Call, state: dict[Key, Dep]) -> Dep:
if project is None:
project = self.project.get(target.symbol.attribute("__init__"))
if project is not None:
bound = self.receiver(call, project.name)
bound = () if project.static else self.receiver(call, project.name)
arguments = (*(self.deep(r, state) for r in bound), *arguments)
return self.known(project, arguments, keywords, everything, call, state, bound)
self.record(
Expand All @@ -337,9 +340,10 @@ def call(self, call: Call, state: dict[Key, Dep]) -> Dep:
made = self.defs.get(call.callee)
if isinstance(made, MakeFunction):
arguments = (*arguments, *(self.deep(v, state) for v in made.captured))
bound = self.receiver(call, target.name)
callee_summary = self.table[target.name]
bound = () if callee_summary.static else self.receiver(call, target.name)
arguments = (*(self.deep(r, state) for r in bound), *arguments)
return self.known(self.table[target.name], arguments, keywords, everything, call, state, bound)
return self.known(callee_summary, arguments, keywords, everything, call, state, bound)
assert isinstance(target, UnknownTarget)
return everything | state.get(call.callee, EMPTY)

Expand All @@ -357,7 +361,7 @@ def nonlocal_result(self, instruction: NonlocalResult, state: dict[Key, Dep]) ->
made = self.defs.get(call.callee)
if isinstance(made, MakeFunction):
arguments = (*arguments, *(self.deep(v, state) for v in made.captured))
bound = self.receiver(call, target.name)
bound = () if callee.static else self.receiver(call, target.name)
arguments = (*(self.deep(r, state) for r in bound), *arguments)
keywords = EMPTY
for value in (*call.starred, *(v for _, v in call.keywords)):
Expand Down Expand Up @@ -447,6 +451,7 @@ def summarize(
table: Mapping[str, FunctionSummary],
project: Mapping[SymbolId, FunctionSummary] | None = None,
heap: HeapFacts | None = None,
static: bool = False,
) -> FunctionSummary:
problem = _DependenceProblem(name, function, graph, table, project, heap)
solution = solve(problem, cfg)
Expand Down Expand Up @@ -479,6 +484,7 @@ def summarize(
for name, deps in sorted(problem.nonlocal_stores.items())
if deps.parameters or deps.externals
),
static=static,
)


Expand Down Expand Up @@ -509,7 +515,9 @@ def compute(cls, ctx: AnalysisContext) -> SummaryTable:
while changed:
changed = False
for name, (ssa, cfg, heap) in supported.items():
updated = summarize(name, ssa, cfg, graph, table, project, heap)
updated = summarize(
name, ssa, cfg, graph, table, project, heap, _is_staticmethod(graph.definitions[name])
)
if updated != table[name]:
table[name] = updated
changed = True
Expand Down
Loading
Loading