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

### Detection

- `code-injection` reports attacker input reaching the code `eval` or `exec` runs, with the same verdicts as the other taint rules. The globals and locals passed with the code are data, not code. `dangerous-eval` still reports every call to `eval` or `exec`, whatever it runs. The regression corpus gains three findings, each reviewed as a true positive: pygoat's `mitre_lab_25_api` and `cmd_lab2` evaluate a POST field, and the `vulnerable-flask` fixture evaluates a query parameter (#152).

## 0.10.0 (2026-09-25)

### Reports
Expand Down
1 change: 1 addition & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ variable is not treated that way yet.
| Rule | Reached sink |
|---|---|
| `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. |
| `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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ class PythonStdlibModels(ModelPlugin):
Sink(_sym("subprocess.check_call"), TaintKind.COMMAND),
Sink(_sym("subprocess.check_output"), TaintKind.COMMAND),
Sink(_sym("subprocess.Popen"), TaintKind.COMMAND),
Sink(_sym("builtins.eval"), TaintKind.CODE),
Sink(_sym("builtins.exec"), TaintKind.CODE),
# The code is the first argument; the globals and locals it runs with are data.
Sink(_sym("builtins.eval"), TaintKind.CODE, ((TaintKind.CODE, (0,)),)),
Sink(_sym("builtins.exec"), TaintKind.CODE, ((TaintKind.CODE, (0,)),)),
Sink(_sym("builtins.open"), TaintKind.PATH),
Sink(_sym("os.remove"), TaintKind.PATH),
Sink(_sym("os.unlink"), TaintKind.PATH),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Code injection: attacker-controlled input reaching a CODE sink, such as the code
``eval`` or ``exec`` runs. ``dangerous-eval`` reports every such call; this rule reports
the ones attacker input reaches."""

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 CodeInjectionPlugin(TaintDetector):
name: ClassVar[str] = "code-injection"
rule_id: ClassVar[str] = "code-injection"
kind: ClassVar[TaintKind] = TaintKind.CODE
severity: ClassVar[Severity] = Severity.HIGH
title: ClassVar[str] = "Code injection"
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "code-injection"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["taint.flows", "findings.refutation"]
provides = ["vulnerability.code-injection"]

[entrypoint]
module = "code_injection"
class = "CodeInjectionPlugin"
14 changes: 14 additions & 0 deletions tests/regression/expected/pygoat.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@
"rule": "weak-crypto",
"function": "csrf_lab_login"
},
{
"file": "introduction/mitre.py",
"line": 218,
"rule": "code-injection",
"function": "mitre_lab_25_api",
"verdict": "vulnerability"
},
{
"file": "introduction/mitre.py",
"line": 218,
Expand Down Expand Up @@ -175,6 +182,13 @@
"function": "cmd_lab",
"verdict": "vulnerability"
},
{
"file": "introduction/views.py",
"line": 460,
"rule": "code-injection",
"function": "cmd_lab2",
"verdict": "vulnerability"
},
{
"file": "introduction/views.py",
"line": 460,
Expand Down
7 changes: 7 additions & 0 deletions tests/regression/expected/vulnerable-flask.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@
"function": "cross_site_scripting",
"verdict": "vulnerability"
},
{
"file": "app.py",
"line": 71,
"rule": "code-injection",
"function": "code_injection",
"verdict": "vulnerability"
},
{
"file": "app.py",
"line": 71,
Expand Down
68 changes: 68 additions & 0 deletions tests/test_code_injection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Code injection: attacker input reaching the code ``eval`` or ``exec`` runs.

``dangerous-eval`` reports every call to ``eval`` or ``exec``, whatever it evaluates;
``code-injection`` reports the calls attacker input reaches, through the same taint
engine and refutation as the other injection rules. Only the code counts: attacker data
in the ``globals`` or ``locals`` a call passes is data, not code.
"""

from __future__ import annotations

import pytest

from coretrace_python import engine
from coretrace_python.findings import Finding, Severity
from coretrace_python.source import SourceManager

PLUGINS = [engine.BUNDLED_PLUGINS]


def check(text: str) -> tuple[Finding, ...]:
return engine.check(SourceManager().add_source("app.py", text), PLUGINS)


def injected(text: str) -> list[int]:
return [f.span.start_line for f in check(text) if f.rule_id == "code-injection"]


@pytest.mark.parametrize(
"body",
[
" return eval(input())\n",
" exec(input())\n",
" code = compile(input(), '<input>', 'exec')\n exec(code)\n",
" source = 'result = ' + input()\n exec(source, {})\n",
],
)
def test_attacker_input_in_the_code_eval_or_exec_runs_is_a_code_injection(body: str) -> None:
findings = [f for f in check(f"def run():\n{body}") if f.rule_id == "code-injection"]

assert [f.span.start_line for f in findings] == [len(body.splitlines()) + 1]
assert findings[0].severity is Severity.HIGH
assert findings[0].metadata["source_label"] == "stdin"


@pytest.mark.parametrize(
"body",
[
" return eval('1 + 1')\n",
" return eval('x + 1', {'x': input()})\n",
" exec('total = a + b', {}, {'a': input(), 'b': 1})\n",
" import ast\n return ast.literal_eval(input())\n",
],
)
def test_constant_code_or_attacker_data_beside_it_is_no_code_injection(body: str) -> None:
assert injected(f"def run():\n{body}") == []


def test_dangerous_eval_still_reports_the_call_beside_the_injection() -> None:
rules = sorted(f.rule_id for f in check("def run():\n return eval(input())\n"))

assert rules == ["code-injection", "dangerous-eval"]


def test_code_reaching_eval_through_a_project_function_is_reported_at_the_call() -> None:
text = "def evaluate(expression):\n return eval(expression)\n\ndef run():\n return evaluate(input())\n"

(finding,) = [f for f in check(text) if f.rule_id == "code-injection"]
assert (finding.span.start_line, finding.metadata["through"], finding.metadata["sink_line"]) == (5, "evaluate", "2")
3 changes: 2 additions & 1 deletion tests/test_detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def test_shipped_plugins_load_with_their_manifests() -> None:
"aiohttp-models",
"bottle-models",
"cli-models",
"code-injection",
"command-injection",
"config-secrets",
"credential-models",
Expand Down Expand Up @@ -198,7 +199,7 @@ def test_shipped_plugins_load_with_their_manifests() -> None:
"weak-crypto",
"xss",
}
for rule in ("sql-injection", "command-injection", "path-traversal", "ssrf", "xss"):
for rule in ("sql-injection", "command-injection", "code-injection", "path-traversal", "ssrf", "xss"):
assert by_name[rule].requires == ("taint.flows", "findings.refutation")
assert by_name[rule].provides == (f"vulnerability.{rule}",)
assert by_name["python-stdlib-models"].provides == ("model.python-stdlib",)
Expand Down
1 change: 1 addition & 0 deletions tests/test_django_http_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ def test_httpx_client_instances_and_sessions_are_ssrf_sinks() -> None:

def test_detectors_stay_generic() -> None:
assert sorted(p.name for p in (PLUGINS / "security").iterdir() if p.is_dir()) == [
"code_injection",
"command_injection",
"insecure_deserialization",
"open_redirect",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def test_every_bundled_plugin_is_named_in_the_guides() -> None:
re.search(r'^name = "([a-z-]+)"', manifest.read_text(encoding="utf-8"), re.MULTILINE).group(1) # type: ignore[union-attr]
for manifest in engine.BUNDLED_PLUGINS.rglob("plugin.toml")
]
assert len(names) == 35
assert len(names) == 36
assert all(f"`{name}`" in text for name in names), [n for n in names if f"`{n}`" not in text]


Expand Down
4 changes: 2 additions & 2 deletions tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def test_bundled_plugins_live_inside_the_package() -> None:
assert bundled == Path(coretrace_python.__file__).resolve().parent / "bundled"
assert bundled.is_dir()
manifests = sorted(p.relative_to(bundled).as_posix() for p in bundled.rglob("plugin.toml"))
assert len(manifests) == 35
assert len(manifests) == 36
assert "syntax/dangerous_eval/plugin.toml" in manifests
assert not (REPO / "plugins").exists()

Expand Down Expand Up @@ -108,7 +108,7 @@ def test_the_wheel_carries_the_bundled_plugins(tmp_path: Path) -> None:

assert "coretrace_python/bundled/syntax/dangerous_eval/plugin.toml" in names
assert "coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py" in names
assert sum(1 for n in names if n.endswith("/plugin.toml")) == 35
assert sum(1 for n in names if n.endswith("/plugin.toml")) == 36
assert not any(n.startswith("tests/") or "tests-project" in n for n in names)


Expand Down
Loading