From 9d2c7cd8b530192918013aa061671f727e88c09a Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 25 Sep 2026 23:06:07 +0200 Subject: [PATCH 1/3] test: specify the code-injection rule for attacker input reaching eval and exec --- tests/test_code_injection.py | 68 +++++++++++++++++++++++++++++++ tests/test_detectors.py | 3 +- tests/test_django_http_clients.py | 1 + tests/test_docs.py | 2 +- tests/test_packaging.py | 2 +- 5 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/test_code_injection.py diff --git a/tests/test_code_injection.py b/tests/test_code_injection.py new file mode 100644 index 0000000..f9860cc --- /dev/null +++ b/tests/test_code_injection.py @@ -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(), '', '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") diff --git a/tests/test_detectors.py b/tests/test_detectors.py index c5a8b9d..dc370ee 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -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", @@ -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",) diff --git a/tests/test_django_http_clients.py b/tests/test_django_http_clients.py index 86fee8e..644a1c8 100644 --- a/tests/test_django_http_clients.py +++ b/tests/test_django_http_clients.py @@ -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", diff --git a/tests/test_docs.py b/tests/test_docs.py index 6865c7f..25979ae 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -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] diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 0fce742..a37381a 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -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() From 012fb3340ab4601d523b1a1576eb34fcf77a2508 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 25 Sep 2026 23:06:07 +0200 Subject: [PATCH 2/3] feat: report attacker input reaching eval and exec as code injection eval and exec were CODE sinks with no rule reporting their flows, so the only finding was dangerous-eval, whatever the call ran. code-injection reports the calls attacker input reaches, with the refutation verdicts, and the sinks now read the code argument only: the globals and locals are data. The regression corpus gains three reviewed true positives. --- CHANGELOG.md | 6 ++++++ docs/usage.md | 1 + .../models/python_stdlib/python_stdlib.py | 5 +++-- .../security/code_injection/code_injection.py | 19 +++++++++++++++++++ .../security/code_injection/plugin.toml | 9 +++++++++ tests/regression/expected/pygoat.json | 14 ++++++++++++++ .../regression/expected/vulnerable-flask.json | 7 +++++++ 7 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/coretrace_python/bundled/security/code_injection/code_injection.py create mode 100644 src/coretrace_python/bundled/security/code_injection/plugin.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e99a44..af11d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/usage.md b/docs/usage.md index d3f3c90..4e4c401 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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. | diff --git a/src/coretrace_python/bundled/models/python_stdlib/python_stdlib.py b/src/coretrace_python/bundled/models/python_stdlib/python_stdlib.py index 65ee999..ec2bbb5 100644 --- a/src/coretrace_python/bundled/models/python_stdlib/python_stdlib.py +++ b/src/coretrace_python/bundled/models/python_stdlib/python_stdlib.py @@ -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), diff --git a/src/coretrace_python/bundled/security/code_injection/code_injection.py b/src/coretrace_python/bundled/security/code_injection/code_injection.py new file mode 100644 index 0000000..495bca4 --- /dev/null +++ b/src/coretrace_python/bundled/security/code_injection/code_injection.py @@ -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" diff --git a/src/coretrace_python/bundled/security/code_injection/plugin.toml b/src/coretrace_python/bundled/security/code_injection/plugin.toml new file mode 100644 index 0000000..b0a4e23 --- /dev/null +++ b/src/coretrace_python/bundled/security/code_injection/plugin.toml @@ -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" diff --git a/tests/regression/expected/pygoat.json b/tests/regression/expected/pygoat.json index 8cef4a8..9a381e3 100644 --- a/tests/regression/expected/pygoat.json +++ b/tests/regression/expected/pygoat.json @@ -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, @@ -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, diff --git a/tests/regression/expected/vulnerable-flask.json b/tests/regression/expected/vulnerable-flask.json index 1257b75..0d00202 100644 --- a/tests/regression/expected/vulnerable-flask.json +++ b/tests/regression/expected/vulnerable-flask.json @@ -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, From 64b0f95358e42e0d58454805652c9e4ba03ad999 Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 25 Sep 2026 23:09:49 +0200 Subject: [PATCH 3/3] test: count the code-injection plugin among the wheel's bundled plugins --- tests/test_packaging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index a37381a..10143ae 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -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)