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

### Precision

- The SSRF sinks of Requests and httpx read the destination only: the URL (the first argument, the second after the method for `request` and `stream`, or `url=`) or the prepared request `send` takes. Attacker data in the body, the JSON, the headers or the query parameters is no longer a server-side request forgery. `Sink` gains `keywords` next to `positions`, and function summaries keep the name of each keyword argument, so a project function forwarding `url=` still reaches the sink; the cache format is bumped (#128).

## 0.6.0 (2026-09-24)

### Reports
Expand Down
4 changes: 2 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,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. |
| `Sink(symbol, kinds, positions=())` | A call dangerous for `kinds`; `positions` limits a kind to given argument indexes, as SQL sinks read the statement only. |
| `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`. |
| `EntryPoint(symbol, label, kinds=ALL)` | A decorator or a class base whose functions receive their parameters as `label` input, such as `flask.Flask.route`. |
| `TypedParameter(symbol, label, kinds=ALL)` | A class whose annotated parameters carry `label` input, such as Django's `HttpRequest`. |
Expand All @@ -153,7 +153,7 @@ class BottleModels(ModelPlugin):
| `AuthorizationGuard(symbol, label)` | A decorator or a condition restricting who reaches the code, such as `login_required`; a flow behind it is a hotspot. |

Two plugins may describe the same symbol. An identical model is registered once; two
`Sink` models merge their kinds and positions, so a plugin can add `SQL` to a sink
`Sink` models merge their kinds, positions and keywords, so a plugin can add `SQL` to a sink
another plugin declared for `COMMAND`; any other difference is a conflict that stops
the analysis with a `ModelError` naming both plugins.

Expand Down
2 changes: 1 addition & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ a sanitizer for that kind of sink.
| `command-injection` | Shell or process execution (`os.system`, `subprocess` with a string, …). |
| `sql-injection` | A database statement (`cursor.execute`, SQLAlchemy `text`, Django `raw`, …). Parameters of a parameterised query are not statements. |
| `path-traversal` | A file system path (`open`, `send_file`, `os.remove`, …). |
| `ssrf` | The URL of an HTTP client request (Requests, httpx, `urllib`). |
| `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. |
| `insecure-deserialization` | `pickle`, `marshal`, `dill`, `jsonpickle`, `shelve`, and the YAML loaders (`yaml.load`, `load_all`, `full_load`, `unsafe_load` and their several-document versions); `yaml.load` and `load_all` given a safe loader (`SafeLoader`, `BaseLoader`) are not. |
| `open-redirect` | An HTTP redirect target. |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Requests and httpx models: every request function is a SSRF sink, and what it returns
is an ``http-response`` source, so data fetched from a remote server is untrusted."""
"""Requests and httpx models: every request function is a SSRF sink for the argument that
chooses the destination, and what it returns is an ``http-response`` source, so data
fetched from a remote server is untrusted. The destination is the URL — first, or second
after the method, or ``url=`` — or the prepared request a ``send`` takes; the body, the
headers and the query parameters do not choose the host."""

from __future__ import annotations

Expand All @@ -20,15 +23,18 @@
"httpx.AsyncClient",
)

_REQUEST_FUNCTIONS = (
*(f"{caller}.{method}" for caller in _CALLERS for method in _METHODS),
"requests.Session.send",
"httpx.stream",
"httpx.Client.stream",
"httpx.AsyncClient.stream",
"httpx.Client.send",
"httpx.AsyncClient.send",
)
# Where each function takes its destination, by position (the receiver of a method
# excluded) and by keyword, as its signature declares it.
_DESTINATIONS = {
**{f"{caller}.{method}": (0, "url") for caller in _CALLERS for method in _METHODS if method != "request"},
**{f"{caller}.request": (1, "url") for caller in _CALLERS},
"httpx.stream": (1, "url"),
"httpx.Client.stream": (1, "url"),
"httpx.AsyncClient.stream": (1, "url"),
"requests.Session.send": (0, "request"),
"httpx.Client.send": (0, "request"),
"httpx.AsyncClient.send": (0, "request"),
}


def _sym(path: str) -> SymbolId:
Expand All @@ -38,6 +44,9 @@ def _sym(path: str) -> SymbolId:
class HttpClientModels(ModelPlugin):
name: ClassVar[str] = "http-client-models"
models: ClassVar[tuple[Model, ...]] = (
*(Sink(_sym(function), TaintKind.SSRF) for function in _REQUEST_FUNCTIONS),
*(Source(_sym(function), "http-response") for function in _REQUEST_FUNCTIONS),
*(
Sink(_sym(function), TaintKind.SSRF, ((TaintKind.SSRF, (position,)),), ((TaintKind.SSRF, (keyword,)),))
for function, (position, keyword) in _DESTINATIONS.items()
),
*(Source(_sym(function), "http-response") for function in _DESTINATIONS),
)
6 changes: 3 additions & 3 deletions src/coretrace_python/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.source import SourceId, SourceSpan

CACHE_FORMAT = 8
CACHE_FORMAT = 9


@dataclass(frozen=True)
Expand Down Expand Up @@ -233,7 +233,7 @@ def _encode_call(call: ExternalCall) -> dict[str, Any]:
return {
"symbol": str(call.symbol),
"arguments": [sorted(deps) for deps in call.argument_dependencies],
"keywords": sorted(call.keyword_dependencies),
"keywords": [[name, sorted(deps)] for name, deps in call.keyword_dependencies],
"location": _encode_span(call.location),
"call_site": None if call.call_site is None else _encode_span(call.call_site),
"given": _encode_arguments(call.arguments),
Expand All @@ -245,7 +245,7 @@ def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
return ExternalCall(
SymbolId(_string(data["symbol"])),
tuple(_indices(deps) for deps in data["arguments"]),
_indices(data["keywords"]),
tuple((None if name is None else _string(name), _indices(deps)) for name, deps in data["keywords"]),
_decode_span(data["location"]),
None if site is None else _decode_span(site),
_decode_arguments(data["given"]),
Expand Down
26 changes: 18 additions & 8 deletions src/coretrace_python/interprocedural/summaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,13 @@ def __or__(self, other: Dep) -> Dep:

@dataclass(frozen=True)
class ExternalCall:
"""An external symbol reached from this function; dependencies are parameter indices."""
"""An external symbol reached from this function; dependencies are parameter indices.
Keyword arguments keep their name, so a sink reading ``url=`` only can be decided;
``*args`` and ``**kwargs`` have none."""

symbol: SymbolId
argument_dependencies: tuple[Dependencies, ...]
keyword_dependencies: Dependencies
keyword_dependencies: tuple[tuple[str | None, Dependencies], ...]
location: SourceSpan
call_site: SourceSpan | None
# What the arguments of the call at ``location`` denote, where it is made.
Expand Down Expand Up @@ -313,9 +315,13 @@ def loaded(self, instruction: GetAttr | GetItem | GetIter, state: Mapping[Key, D

def call(self, call: Call, state: dict[Key, Dep]) -> Dep:
arguments = tuple(self.deep(a, state) for a in call.arguments)
named = (
*((name, self.deep(value, state)) for name, value in call.keywords),
*((None, self.deep(value, state)) for value in call.starred),
)
keywords = EMPTY
for value in (*call.starred, *(v for _, v in call.keywords)):
keywords |= self.deep(value, state)
for _, deps in named:
keywords |= deps
everything = keywords
for deps in arguments:
everything |= deps
Expand All @@ -335,7 +341,7 @@ def call(self, call: Call, state: dict[Key, Dep]) -> Dep:
self.record(
target.symbol,
tuple(a.parameters for a in arguments),
keywords.parameters,
tuple((name, deps.parameters) for name, deps in named),
call.location,
None,
self.graph.arguments_at(self.name, call.location),
Expand Down Expand Up @@ -418,7 +424,7 @@ def mapped(indices: Dependencies) -> Dep:
self.record(
reached.symbol,
tuple(mapped(d).parameters for d in reached.argument_dependencies),
mapped(reached.keyword_dependencies).parameters,
tuple((name, mapped(d).parameters) for name, d in reached.keyword_dependencies),
reached.location,
call.location,
reached.arguments,
Expand All @@ -435,7 +441,7 @@ def record(
self,
symbol: SymbolId,
arguments: tuple[Dependencies, ...],
keywords: Dependencies,
keywords: tuple[tuple[str | None, Dependencies], ...],
location: SourceSpan,
call_site: SourceSpan | None,
given: Arguments,
Expand All @@ -446,7 +452,11 @@ def record(
arguments = tuple(
a | b for a, b in zip(previous.argument_dependencies, arguments, strict=False)
)
keywords |= previous.keyword_dependencies
# The same call records the same keywords, in the same order.
keywords = tuple(
(name, deps | old)
for (name, deps), (_, old) in zip(keywords, previous.keyword_dependencies, strict=False)
)
self.external[key] = ExternalCall(symbol, arguments, keywords, location, call_site, given)


Expand Down
22 changes: 16 additions & 6 deletions src/coretrace_python/taint/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass
from dataclasses import dataclass, replace
from types import MappingProxyType
from typing import ClassVar

Expand Down Expand Up @@ -401,8 +401,12 @@ def external(
if sink is not None:
for position, argument in enumerate(call.arguments):
self.report(flows, sink, self.rendered(argument, state), argument, call, None, None, position, given)
for argument in (*call.starred, *(value for _, value in call.keywords)):
for argument in call.starred:
self.report(flows, sink, self.rendered(argument, state), argument, call, None, None, None, given)
for name, argument in call.keywords:
self.report(
flows, sink, self.rendered(argument, state), argument, call, None, None, None, given, keyword=name
)
sanitizer = self.models.sanitizer(symbol)
if sanitizer is not None:
return everything.without(sanitizer.kinds)
Expand All @@ -424,7 +428,7 @@ def guarded(self, sink: Sink | None, arguments: Arguments) -> Sink | None:
if safe is None or given is None or not given[0] or given[1] not in safe.values:
return sink
kinds = sink.kinds & ~safe.kinds
return Sink(sink.symbol, kinds, sink.positions) if kinds else None
return replace(sink, kinds=kinds) if kinds else None

def rendered(self, argument: Value, state: Mapping[Key, Taint]) -> Taint:
"""The taint an argument brings to a sink. A container literal is serialised by
Expand Down Expand Up @@ -497,11 +501,16 @@ def mapped(deps: frozenset[int]) -> tuple[Taint, Value | None]:
sink = self.guarded(self.models.sink(reached.symbol), reached.arguments)
if sink is None:
continue
positions: list[int | None] = [*range(len(reached.argument_dependencies)), None]
for position, deps in zip(positions, (*reached.argument_dependencies, reached.keyword_dependencies), strict=True):
for position, deps in enumerate(reached.argument_dependencies):
taint, witness = mapped(deps)
if witness is not None:
self.report(flows, sink, taint, witness, call, through, reached.location, position, reached.arguments)
for name, deps in reached.keyword_dependencies:
taint, witness = mapped(deps)
if witness is not None:
self.report(
flows, sink, taint, witness, call, through, reached.location, None, reached.arguments, keyword=name
)
for mutation in summary.mutations:
if mutation.parameter < len(values):
stored = mapped(mutation.dependencies)[0]
Expand All @@ -528,8 +537,9 @@ def report(
sink_location: SourceSpan | None,
position: int | None = None,
arguments: Arguments | None = None,
keyword: str | None = None,
) -> None:
reaching = taint.kinds & sink.kinds_at(position)
reaching = taint.kinds & sink.kinds_at(position, keyword)
if not reaching:
return
for source in sorted(taint.sources, key=lambda s: str(s.symbol)):
Expand Down
44 changes: 29 additions & 15 deletions src/coretrace_python/taint/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,42 @@ class Source:

@dataclass(frozen=True)
class Sink:
"""A callable whose arguments must not carry the given taint kinds. ``positions``
restricts some kinds to argument positions: a SQL statement is the first argument
of ``execute``, its parameter tuple is not a statement."""
"""A callable whose arguments must not carry the given taint kinds. ``positions`` and
``keywords`` restrict some kinds to the arguments they list: a SQL statement is the
first argument of ``execute``, its parameter tuple is not a statement; the target of
``requests.get`` is its first argument or ``url=``, its body is no destination."""

symbol: SymbolId
kinds: TaintKind
positions: tuple[tuple[TaintKind, tuple[int, ...]], ...] = ()

def kinds_at(self, position: int | None) -> TaintKind:
"""The kinds that must not reach the argument at ``position`` (``None`` for a
keyword or starred argument)."""

kinds = self.kinds
for restricted, allowed in self.positions:
if position is None or position not in allowed:
kinds &= ~restricted
return kinds
keywords: tuple[tuple[TaintKind, tuple[str, ...]], ...] = ()

def kinds_at(self, position: int | None, keyword: str | None = None) -> TaintKind:
"""The kinds that must not reach the argument at ``position`` or passed as
``keyword``; both are ``None`` for a starred argument or ``**kwargs``, which
reach no restricted kind. A restricted kind reaches any argument one of its
restrictions lists."""

restricted = allowed = TaintKind(0)
for kinds, positions in self.positions:
restricted |= kinds
if position in positions:
allowed |= kinds
for kinds, names in self.keywords:
restricted |= kinds
if keyword in names:
allowed |= kinds
return self.kinds & ~(restricted & ~allowed)

def merged(self, other: Sink) -> Sink:
"""This sink with ``other``'s kinds and positions added."""
"""This sink with ``other``'s kinds, positions and keywords added."""

return Sink(self.symbol, self.kinds | other.kinds, self.positions + other.positions)
return Sink(
self.symbol,
self.kinds | other.kinds,
self.positions + other.positions,
self.keywords + other.keywords,
)


@dataclass(frozen=True)
Expand Down
6 changes: 0 additions & 6 deletions tests/regression/expected/httpie.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,6 @@
"rule": "ssrf",
"function": "collect_messages"
},
{
"file": "httpie/client.py",
"line": 114,
"rule": "ssrf",
"function": "collect_messages"
},
{
"file": "httpie/internal/update_warnings.py",
"line": 44,
Expand Down
6 changes: 0 additions & 6 deletions tests/regression/expected/pygoat.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,6 @@
"rule": "missing-timeout",
"function": "log_function_checker"
},
{
"file": "introduction/apis.py",
"line": 83,
"rule": "ssrf",
"function": "log_function_checker"
},
{
"file": "introduction/apis.py",
"line": 84,
Expand Down
11 changes: 10 additions & 1 deletion tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,16 @@ def test_findings_and_summaries_round_trip_through_json() -> None:
"f",
2,
frozenset({0}),
(ExternalCall(SymbolId("python.os.system"), (frozenset({0}), frozenset()), frozenset({1}), span, None, arguments),),
(
ExternalCall(
SymbolId("python.os.system"),
(frozenset({0}), frozenset()),
(("url", frozenset({1})), (None, frozenset({0, 1}))),
span,
None,
arguments,
),
),
False,
frozenset({SymbolId("python.builtins.input")}),
)
Expand Down
9 changes: 6 additions & 3 deletions tests/test_interprocedural.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,15 @@ def test_return_values_record_the_external_results_they_depend_on() -> None:
assert table.summary("const").return_externals == frozenset()


def test_keyword_arguments_are_conservatively_merged() -> None:
table = summaries("import subprocess\n\ndef run(cmd, flag):\n subprocess.run(cmd, check=flag)\n")
def test_keyword_arguments_keep_their_names() -> None:
table = summaries(
"import subprocess\n\ndef run(cmd, flag, options):\n subprocess.run(cmd, check=flag, **options)\n"
)
(call,) = table.summary("run").external_calls

assert call.argument_dependencies == (frozenset({0}),)
assert call.keyword_dependencies == frozenset({1})
# A sink may read one keyword only (``url=``); ``**options`` has no name.
assert call.keyword_dependencies == (("check", frozenset({1})), (None, frozenset({2})))


def test_recursive_summaries_reach_a_fixpoint() -> None:
Expand Down
Loading
Loading