From c3d8f8cf954118848d0aba5a1e9c79a729e4eeb5 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 23 Sep 2026 07:52:21 +0200 Subject: [PATCH 1/3] test: define the analysis of module bodies and class bodies as functions --- tests/regression/expected/Chatbot.json | 4 +- tests/regression/expected/DjangoGoat.json | 4 +- .../expected/Flask-Image-Editing-Website.json | 4 +- tests/regression/expected/InsecureGram.json | 4 +- .../expected/MiniBookApiServer.json | 4 +- tests/regression/expected/NPL-IA.json | 4 +- .../expected/Spam-Detection-Project.json | 4 +- .../expected/Vulnerable_fastapi.json | 94 ++++++++++- tests/regression/expected/dvpwa.json | 4 +- .../expected/full-stack-fastapi-template.json | 12 +- tests/regression/expected/healthchecks.json | 4 +- tests/regression/expected/httpie.json | 10 +- tests/regression/expected/luigi.json | 4 +- tests/regression/expected/my_gdb.json | 4 +- .../expected/network-topologer.json | 4 +- tests/regression/expected/pygoat.json | 10 +- .../uploadbox-cloud-storage-solution.json | 4 +- .../regression/expected/vulnerable-flask.json | 4 +- tests/regression/expected/yt-fts.json | 4 +- tests/test_baseline.py | 2 +- tests/test_class_keywords_with_targets.py | 4 +- tests/test_closures.py | 10 +- tests/test_discovery.py | 2 +- tests/test_module_level.py | 146 ++++++++++++++++++ tests/test_plugins.py | 6 +- tests/test_project.py | 2 +- tests/test_pyir_imports.py | 2 +- tests/test_remaining_limits.py | 2 +- tests/test_syntax_control_flow.py | 4 +- tests/test_syntax_coverage.py | 6 +- 30 files changed, 308 insertions(+), 64 deletions(-) create mode 100644 tests/test_module_level.py diff --git a/tests/regression/expected/Chatbot.json b/tests/regression/expected/Chatbot.json index 7f13a3c..3c2dc8c 100644 --- a/tests/regression/expected/Chatbot.json +++ b/tests/regression/expected/Chatbot.json @@ -2,8 +2,8 @@ "coverage": { "files": 16, "files_analysed": 16, - "functions": 176, - "functions_analysed": 176 + "functions": 191, + "functions_analysed": 191 }, "findings": [ { diff --git a/tests/regression/expected/DjangoGoat.json b/tests/regression/expected/DjangoGoat.json index bbdfbb5..e5ee470 100644 --- a/tests/regression/expected/DjangoGoat.json +++ b/tests/regression/expected/DjangoGoat.json @@ -2,8 +2,8 @@ "coverage": { "files": 29, "files_analysed": 29, - "functions": 39, - "functions_analysed": 39 + "functions": 71, + "functions_analysed": 71 }, "findings": [ { diff --git a/tests/regression/expected/Flask-Image-Editing-Website.json b/tests/regression/expected/Flask-Image-Editing-Website.json index 71ce7eb..7eaa492 100644 --- a/tests/regression/expected/Flask-Image-Editing-Website.json +++ b/tests/regression/expected/Flask-Image-Editing-Website.json @@ -2,8 +2,8 @@ "coverage": { "files": 1, "files_analysed": 1, - "functions": 4, - "functions_analysed": 4 + "functions": 5, + "functions_analysed": 5 }, "findings": [ { diff --git a/tests/regression/expected/InsecureGram.json b/tests/regression/expected/InsecureGram.json index b5146fc..6e476f4 100644 --- a/tests/regression/expected/InsecureGram.json +++ b/tests/regression/expected/InsecureGram.json @@ -2,8 +2,8 @@ "coverage": { "files": 15, "files_analysed": 15, - "functions": 15, - "functions_analysed": 15 + "functions": 34, + "functions_analysed": 34 }, "findings": [ { diff --git a/tests/regression/expected/MiniBookApiServer.json b/tests/regression/expected/MiniBookApiServer.json index dbbe5a2..f7dfff8 100644 --- a/tests/regression/expected/MiniBookApiServer.json +++ b/tests/regression/expected/MiniBookApiServer.json @@ -2,8 +2,8 @@ "coverage": { "files": 6, "files_analysed": 6, - "functions": 40, - "functions_analysed": 40 + "functions": 49, + "functions_analysed": 49 }, "findings": [ { diff --git a/tests/regression/expected/NPL-IA.json b/tests/regression/expected/NPL-IA.json index bf4243d..a0abbd3 100644 --- a/tests/regression/expected/NPL-IA.json +++ b/tests/regression/expected/NPL-IA.json @@ -2,8 +2,8 @@ "coverage": { "files": 1, "files_analysed": 1, - "functions": 9, - "functions_analysed": 9 + "functions": 10, + "functions_analysed": 10 }, "findings": [ { diff --git a/tests/regression/expected/Spam-Detection-Project.json b/tests/regression/expected/Spam-Detection-Project.json index d459c9a..e10799d 100644 --- a/tests/regression/expected/Spam-Detection-Project.json +++ b/tests/regression/expected/Spam-Detection-Project.json @@ -2,8 +2,8 @@ "coverage": { "files": 1, "files_analysed": 1, - "functions": 2, - "functions_analysed": 2 + "functions": 3, + "functions_analysed": 3 }, "findings": [ { diff --git a/tests/regression/expected/Vulnerable_fastapi.json b/tests/regression/expected/Vulnerable_fastapi.json index 7119f4d..cfa3dab 100644 --- a/tests/regression/expected/Vulnerable_fastapi.json +++ b/tests/regression/expected/Vulnerable_fastapi.json @@ -2,8 +2,8 @@ "coverage": { "files": 20, "files_analysed": 20, - "functions": 27, - "functions_analysed": 27 + "functions": 46, + "functions_analysed": 46 }, "findings": [ { @@ -18,18 +18,78 @@ "rule": "path-traversal", "function": "download" }, + { + "file": "exploit_scripts/brute_force_login.py", + "line": 18, + "rule": "missing-timeout", + "function": "" + }, { "file": "exploit_scripts/csrf_change_email.py", "line": 22, "rule": "hardcoded-credential", "function": null }, + { + "file": "exploit_scripts/csrf_change_email.py", + "line": 22, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/csrf_change_email.py", + "line": 28, + "rule": "missing-timeout", + "function": "" + }, { "file": "exploit_scripts/idor_orders.py", "line": 12, "rule": "hardcoded-credential", "function": null }, + { + "file": "exploit_scripts/idor_orders.py", + "line": 12, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/idor_orders.py", + "line": 16, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/idor_orders.py", + "line": 22, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/path_traversal_download.py", + "line": 13, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/path_traversal_download.py", + "line": 18, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/reflected_xss_search.py", + "line": 15, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/sqli_login_bypass.py", + "line": 22, + "rule": "missing-timeout", + "function": "" + }, { "file": "exploit_scripts/sqli_login_bypass.py", "line": 24, @@ -41,6 +101,36 @@ "line": 19, "rule": "hardcoded-credential", "function": null + }, + { + "file": "exploit_scripts/stored_xss_profile.py", + "line": 19, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/stored_xss_profile.py", + "line": 24, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/stored_xss_profile.py", + "line": 24, + "rule": "ssrf", + "function": "" + }, + { + "file": "exploit_scripts/stored_xss_profile.py", + "line": 28, + "rule": "missing-timeout", + "function": "" + }, + { + "file": "exploit_scripts/stored_xss_profile.py", + "line": 28, + "rule": "ssrf", + "function": "" } ] } diff --git a/tests/regression/expected/dvpwa.json b/tests/regression/expected/dvpwa.json index 3b1fd77..1934881 100644 --- a/tests/regression/expected/dvpwa.json +++ b/tests/regression/expected/dvpwa.json @@ -2,8 +2,8 @@ "coverage": { "files": 21, "files_analysed": 21, - "functions": 48, - "functions_analysed": 48 + "functions": 69, + "functions_analysed": 69 }, "findings": [ { diff --git a/tests/regression/expected/full-stack-fastapi-template.json b/tests/regression/expected/full-stack-fastapi-template.json index f3e9c81..3b98cd5 100644 --- a/tests/regression/expected/full-stack-fastapi-template.json +++ b/tests/regression/expected/full-stack-fastapi-template.json @@ -1,9 +1,9 @@ { "coverage": { "files": 43, - "files_analysed": 42, - "functions": 140, - "functions_analysed": 140 + "files_analysed": 43, + "functions": 198, + "functions_analysed": 198 }, "findings": [ { @@ -30,12 +30,6 @@ "rule": "hardcoded-secret", "function": null }, - { - "file": "backend/app/api/deps.py", - "line": 1, - "rule": "syntax-error", - "function": null - }, { "file": "backend/app/api/routes/login.py", "line": 121, diff --git a/tests/regression/expected/healthchecks.json b/tests/regression/expected/healthchecks.json index 65f3618..7ba63f7 100644 --- a/tests/regression/expected/healthchecks.json +++ b/tests/regression/expected/healthchecks.json @@ -2,8 +2,8 @@ "coverage": { "files": 653, "files_analysed": 653, - "functions": 2746, - "functions_analysed": 2746 + "functions": 3679, + "functions_analysed": 3679 }, "findings": [ { diff --git a/tests/regression/expected/httpie.json b/tests/regression/expected/httpie.json index 0488bcb..58dac11 100644 --- a/tests/regression/expected/httpie.json +++ b/tests/regression/expected/httpie.json @@ -2,8 +2,8 @@ "coverage": { "files": 132, "files_analysed": 132, - "functions": 1070, - "functions_analysed": 1070 + "functions": 1305, + "functions_analysed": 1305 }, "findings": [ { @@ -12,6 +12,12 @@ "rule": "missing-timeout", "function": "fetch" }, + { + "file": "docs/contributors/fetch.py", + "line": 266, + "rule": "command-injection", + "function": "" + }, { "file": "httpie/client.py", "line": 114, diff --git a/tests/regression/expected/luigi.json b/tests/regression/expected/luigi.json index fbc7ff0..3025063 100644 --- a/tests/regression/expected/luigi.json +++ b/tests/regression/expected/luigi.json @@ -2,8 +2,8 @@ "coverage": { "files": 259, "files_analysed": 259, - "functions": 5169, - "functions_analysed": 5169 + "functions": 6067, + "functions_analysed": 6067 }, "findings": [ { diff --git a/tests/regression/expected/my_gdb.json b/tests/regression/expected/my_gdb.json index 99682c6..11c38e3 100644 --- a/tests/regression/expected/my_gdb.json +++ b/tests/regression/expected/my_gdb.json @@ -2,8 +2,8 @@ "coverage": { "files": 1, "files_analysed": 1, - "functions": 30, - "functions_analysed": 30 + "functions": 31, + "functions_analysed": 31 }, "findings": [] } diff --git a/tests/regression/expected/network-topologer.json b/tests/regression/expected/network-topologer.json index 258f80b..bcff140 100644 --- a/tests/regression/expected/network-topologer.json +++ b/tests/regression/expected/network-topologer.json @@ -2,8 +2,8 @@ "coverage": { "files": 6, "files_analysed": 6, - "functions": 20, - "functions_analysed": 20 + "functions": 32, + "functions_analysed": 32 }, "findings": [] } diff --git a/tests/regression/expected/pygoat.json b/tests/regression/expected/pygoat.json index 80d31f2..60491d4 100644 --- a/tests/regression/expected/pygoat.json +++ b/tests/regression/expected/pygoat.json @@ -2,8 +2,8 @@ "coverage": { "files": 78, "files_analysed": 76, - "functions": 170, - "functions_analysed": 170 + "functions": 287, + "functions_analysed": 287 }, "findings": [ { @@ -96,6 +96,12 @@ "rule": "missing-timeout", "function": "log_function_checker" }, + { + "file": "introduction/lab_code/test.py", + "line": 23, + "rule": "reachable-vulnerability", + "function": "" + }, { "file": "introduction/mitre.py", "line": 161, diff --git a/tests/regression/expected/uploadbox-cloud-storage-solution.json b/tests/regression/expected/uploadbox-cloud-storage-solution.json index 88ce378..1867454 100644 --- a/tests/regression/expected/uploadbox-cloud-storage-solution.json +++ b/tests/regression/expected/uploadbox-cloud-storage-solution.json @@ -2,8 +2,8 @@ "coverage": { "files": 2, "files_analysed": 2, - "functions": 17, - "functions_analysed": 17 + "functions": 21, + "functions_analysed": 21 }, "findings": [ { diff --git a/tests/regression/expected/vulnerable-flask.json b/tests/regression/expected/vulnerable-flask.json index d51bc93..d737f45 100644 --- a/tests/regression/expected/vulnerable-flask.json +++ b/tests/regression/expected/vulnerable-flask.json @@ -2,8 +2,8 @@ "coverage": { "files": 2, "files_analysed": 2, - "functions": 12, - "functions_analysed": 12 + "functions": 14, + "functions_analysed": 14 }, "findings": [ { diff --git a/tests/regression/expected/yt-fts.json b/tests/regression/expected/yt-fts.json index dc86579..45e424e 100644 --- a/tests/regression/expected/yt-fts.json +++ b/tests/regression/expected/yt-fts.json @@ -2,8 +2,8 @@ "coverage": { "files": 20, "files_analysed": 20, - "functions": 147, - "functions_analysed": 147 + "functions": 166, + "functions_analysed": 166 }, "findings": [ { diff --git a/tests/test_baseline.py b/tests/test_baseline.py index ab94bc2..e57b887 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -104,7 +104,7 @@ def test_the_first_run_records_the_baseline_and_passes(tmp_path: Path, capsys) - out = capsys.readouterr().out assert baseline.is_file() - assert out == "no findings, 1 baselined\ncoverage: 1/1 files, 1/1 functions\n" + assert out == "no findings, 1 baselined\ncoverage: 1/1 files, 2/2 functions\n" def test_only_new_findings_fail_later_runs(tmp_path: Path, capsys) -> None: # type: ignore[no-untyped-def] diff --git a/tests/test_class_keywords_with_targets.py b/tests/test_class_keywords_with_targets.py index 6077dcb..3f0db77 100644 --- a/tests/test_class_keywords_with_targets.py +++ b/tests/test_class_keywords_with_targets.py @@ -73,7 +73,7 @@ def test_methods_of_a_class_with_keyword_arguments_are_analysed() -> None: analysis = engine.analyze_file(SourceManager().add_source("m.py", text), [PLUGINS]) assert rules(analysis.findings) == ["command-injection"] - assert analysis.coverage.summary() == "coverage: 1/1 files, 1/1 functions" + assert analysis.coverage.summary() == "coverage: 1/1 files, 2/2 functions" def test_a_local_class_with_keyword_arguments_evaluates_them() -> None: @@ -81,7 +81,7 @@ def test_a_local_class_with_keyword_arguments_evaluates_them() -> None: analysis = engine.analyze_file(SourceManager().add_source("m.py", text), [PLUGINS]) assert rules(analysis.findings) == ["command-injection"] - assert analysis.coverage.summary() == "coverage: 1/1 files, 2/2 functions" + assert analysis.coverage.summary() == "coverage: 1/1 files, 3/3 functions" def test_with_targets_receive_the_entered_value() -> None: diff --git a/tests/test_closures.py b/tests/test_closures.py index 23cd0e0..9b3acc3 100644 --- a/tests/test_closures.py +++ b/tests/test_closures.py @@ -85,9 +85,9 @@ def test_nested_functions_and_lambdas_are_analysable_functions() -> None: names = [f.name for f in analyzable_functions(module)] - assert names == ["outer", "run", "lambda_8_11"] + assert names == ["", "outer", "run", "lambda_8_11"] functions = lower_module(module).functions - assert [f.name for f in functions] == ["outer", "outer.run", "outer.lambda_8_11"] + assert [f.name for f in functions] == ["", "outer", "outer.run", "outer.lambda_8_11"] def test_captured_variables_become_implicit_parameters() -> None: @@ -109,7 +109,7 @@ def test_captured_variables_become_implicit_parameters() -> None: def test_make_function_carries_the_captured_values() -> None: - (outer, *_) = lower_module(hir(NESTED)).functions + outer = next(f for f in lower_module(hir(NESTED)).functions if f.name == "outer") made = [i for b in outer.blocks for i in b.instructions if isinstance(i, MakeFunction)] assert [(m.name, len(m.captured)) for m in made] == [("run", 2), ("lambda_8_11", 0)] @@ -174,7 +174,7 @@ def test_routes_defined_inside_an_application_factory_are_entry_points() -> None def test_coverage_counts_nested_functions() -> None: analysis = engine.analyze_file(SourceManager().add_source("c.py", NESTED), [PLUGINS]) - assert analysis.coverage.summary() == "coverage: 1/1 files, 3/3 functions" + assert analysis.coverage.summary() == "coverage: 1/1 files, 4/4 functions" def test_call_graph_names_nested_functions_by_their_enclosing_ones() -> None: @@ -183,6 +183,6 @@ def test_call_graph_names_nested_functions_by_their_enclosing_ones() -> None: manager = engine.build_manager(hir(NESTED)) graph = manager.get(CallGraphAnalysis) - assert graph.functions == ("outer", "outer.run", "outer.lambda_8_11") + assert graph.functions == ("", "outer", "outer.run", "outer.lambda_8_11") assert [s.target for s in graph.sites("outer") if isinstance(s.target, KnownFunction)] == [KnownFunction("outer.run")] assert SymbolId("python.c.outer.run") is not None diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 40a3632..f548a11 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -110,7 +110,7 @@ def test_duplicate_qualified_names_get_distinct_call_graph_names() -> None: manager = engine.build_manager(module) graph = manager.get(CallGraphAnalysis) - assert graph.functions == ("Box.cmd", "Box.cmd__2", "run", "run__2") + assert graph.functions == ("", "Box.cmd", "Box.cmd__2", "run", "run__2") setter = module.body[1].body[1] # type: ignore[union-attr] assert graph.name_of(setter) == "Box.cmd__2" # type: ignore[arg-type] assert manager.get(TaintAnalysis, setter) is not None # type: ignore[arg-type] diff --git a/tests/test_module_level.py b/tests/test_module_level.py new file mode 100644 index 0000000..c07f38b --- /dev/null +++ b/tests/test_module_level.py @@ -0,0 +1,146 @@ +"""Module-level statements and class bodies are analysed like functions. + +They are lowered as synthetic functions — ```` for the module body, ``Cls.`` +for a top-level class — so their calls are call sites, their values carry taint and an +advisory's entry point called at import time is reachable. Definitions inside them stay +the separate functions they always were. +""" + +from __future__ import annotations + +from pathlib import Path + +from coretrace_python import engine +from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, dump_advisories +from coretrace_python.findings import Finding, Severity +from coretrace_python.frontend import build_hir +from coretrace_python.ir.lowering import analyzable_functions +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.source import SourceManager + +REPO = Path(__file__).resolve().parent.parent +PLUGINS = REPO / "src" / "coretrace_python" / "bundled" + +ADVISORY = Advisory( + "CVE-2099-0003", + "vulnlib", + "<1.1", + "configure trusts its arguments", + Severity.HIGH, + entry_points=(AdvisoryEntryPoint(SymbolId("python.vulnlib.configure"), "commit abc1234"),), + modules=("vulnlib",), +) + + +def project(root: Path, files: dict[str, str]) -> Path: + for relative, text in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root + + +def rules(findings: tuple[Finding, ...]) -> list[tuple[str, str, int, str | None]]: + return sorted((Path(str(f.span.source_id)).name, f.rule_id, f.span.start_line, f.function) for f in findings) + + +# --------------------------------------------------------------------------- synthetic functions + + +def test_the_module_body_and_class_bodies_are_analysable_functions() -> None: + module = build_hir(SourceManager().add_source("app/settings.py", "import os\n\nDEBUG = os.getenv('D')\n\nclass Config:\n key = os.getenv('K')\n\n def get(self):\n return self.key\n\ndef helper():\n pass\n")) + + names = [f.name for f in analyzable_functions(module)] + + assert names == ["", "Config.", "get", "helper"] + body = analyzable_functions(module)[0] + assert body.parameters == () and body.span == module.span + assert all(not isinstance(s, type(module.body[3])) for s in body.body), "the class definition stays out of the module body" + + +def test_an_empty_module_body_has_no_synthetic_function() -> None: + module = build_hir(SourceManager().add_source("app/empty.py", "def f():\n pass\n")) + + assert [f.name for f in analyzable_functions(module)] == ["f"] + + +# --------------------------------------------------------------------------- findings + + +def test_a_module_level_injection_is_reported_in_the_module_function(tmp_path: Path) -> None: + root = project(tmp_path, {"run.py": "import os\n\nos.system(input())\n"}) + + findings = engine.analyze_project(root, [PLUGINS]).findings + + assert rules(findings) == [("run.py", "command-injection", 3, "")] + + +def test_a_module_level_call_to_an_advisory_entry_point_is_reachable(tmp_path: Path) -> None: + root = project( + tmp_path, + {"requirements.txt": "vulnlib==1.0\n", "project/__init__.py": "", "project/asgi.py": "import vulnlib\n\napplication = vulnlib.configure()\n"}, + ) + (root / "advisories.json").write_text(dump_advisories((ADVISORY,)), encoding="utf-8") + + findings = engine.analyze_project(root, [PLUGINS]).findings + + assert ("asgi.py", "reachable-vulnerability", 3, "") in rules(findings) + + +def test_a_class_body_call_to_an_advisory_entry_point_is_reachable(tmp_path: Path) -> None: + root = project( + tmp_path, + {"requirements.txt": "vulnlib==1.0\n", "forms.py": "import vulnlib\n\nclass Contact:\n field = vulnlib.configure()\n\n def clean(self):\n return self.field\n"}, + ) + (root / "advisories.json").write_text(dump_advisories((ADVISORY,)), encoding="utf-8") + + findings = engine.analyze_project(root, [PLUGINS]).findings + + assert ("forms.py", "reachable-vulnerability", 4, "Contact.") in rules(findings) + + +def test_functions_defined_at_module_level_keep_their_own_analysis(tmp_path: Path) -> None: + root = project(tmp_path, {"run.py": "import os\n\ndef run(c):\n os.system(c)\n\nrun(input())\n"}) + + findings = engine.analyze_project(root, [PLUGINS]).findings + + assert rules(findings) == [("run.py", "command-injection", 6, "")] + assert findings[0].metadata["through"] == "run" + + +def test_a_module_level_lambda_is_analysed_as_a_function_of_the_module(tmp_path: Path) -> None: + """A lambda bound at module level is collected and analysed. Calling it through its + module-level name is not resolved: the body stores it as a global, which the call + graph does not follow back to the lambda (a ceiling, not a regression).""" + + module = build_hir(SourceManager().add_source("run.py", "import os\n\nrun = lambda c: os.system(c)\n")) + + assert [f.name for f in analyzable_functions(module)] == ["", "lambda_3_7"] + + +# --------------------------------------------------------------------------- the body runs as a script + + +def test_module_variables_are_the_body_s_locals_so_taint_connects(tmp_path: Path) -> None: + """Found on the regression corpus: with module variables as globals, ``x = input()`` + did not reach ``os.system(x)`` and a module-level ``for`` had no supported target.""" + + root = project(tmp_path, {"run.py": "import os\n\ncmd = input()\nos.system(cmd)\n\nfor name, value in os.environ.items():\n os.system(value)\n"}) + + analysis = engine.analyze_project(root, [PLUGINS]) + + assert rules(analysis.findings) == [("run.py", "command-injection", 4, "")] + assert analysis.coverage.functions_analysed == analysis.coverage.functions == 1 + + +def test_functions_still_read_module_variables_as_globals(tmp_path: Path) -> None: + module = build_hir(SourceManager().add_source("run.py", "LIMIT = 3\n\ndef f():\n return LIMIT\n")) + from coretrace_python.ir.lowering import lower_module + from coretrace_python.ir.model import Global, StoreLocal + + functions = {f.name: f for f in lower_module(module).functions} + body = [i for b in functions[""].blocks for i in b.instructions] + reader = [i for b in functions["f"].blocks for i in b.instructions] + + assert any(isinstance(i, StoreLocal) and i.name == "LIMIT" for i in body) + assert any(isinstance(i, Global) and i.name == "LIMIT" for i in reader) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 289fb7e..5e4a1c2 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -166,8 +166,8 @@ def test_plugins_consume_declared_analyses() -> None: findings = run_plugins(manager, [UsesSymbols()]) - assert [f.rule_id for f in findings] == ["test.os"] - assert findings[0].span.start_line == 3 + # The module body resolves ``os`` too; ``idle`` shadows it with a parameter. + assert [(f.rule_id, f.span.start_line) for f in findings] == [("test.os", 1), ("test.os", 3)] def test_undeclared_analysis_requests_are_rejected() -> None: @@ -191,7 +191,7 @@ def test_findings_are_returned_in_plugin_order() -> None: findings = run_plugins(manager, [UsesSymbols(), CountFunctions()]) - assert [f.rule_id for f in findings] == ["test.os", "test.function"] + assert [f.rule_id for f in findings] == ["test.os", "test.os", "test.function", "test.function"] # --------------------------------------------------------------------------- version ranges diff --git a/tests/test_project.py b/tests/test_project.py index d4e9816..4134392 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -292,7 +292,7 @@ def test_check_accepts_a_directory(tmp_path: Path, capsys) -> None: # type: ign "app/main.py:4:5: high command-injection: Command injection:" " stdin input reaches python.os.system through app.helpers.execute [run]\n" "1 finding\n" - "coverage: 4/4 files, 3/3 functions\n" + "coverage: 4/4 files, 5/5 functions\n" ) diff --git a/tests/test_pyir_imports.py b/tests/test_pyir_imports.py index c3b64f3..69d1522 100644 --- a/tests/test_pyir_imports.py +++ b/tests/test_pyir_imports.py @@ -45,7 +45,7 @@ def require_imports() -> None: def lower(text: str, *, ssa: bool = False, name: str = "ir.py") -> FunctionIR: module = lower_module(build_hir(SourceManager().add_source(name, text)), ssa=ssa) - return module.functions[0] + return next(f for f in module.functions if f.name != "") def instructions(function: FunctionIR, kind: type[Instruction]) -> list[Instruction]: diff --git a/tests/test_remaining_limits.py b/tests/test_remaining_limits.py index de6c303..89f9638 100644 --- a/tests/test_remaining_limits.py +++ b/tests/test_remaining_limits.py @@ -130,7 +130,7 @@ def test_classes_defined_inside_functions_are_values_with_analysed_methods() -> lowered = printed(text) assert 'make_class "Inner"' in lowered and 'store_local "Inner"' in lowered assert "func @outer.Inner.run" in lowered - assert [f.name for f in analyzable_functions(hir(text))] == ["outer", "run"] + assert [f.name for f in analyzable_functions(hir(text))] == ["", "outer", "run"] assert rules(check(text)) == [("command-injection", 6, "run")] (made,) = [i for f in lower_module(hir(text)).functions for b in f.blocks for i in b.instructions if isinstance(i, MakeClass)] assert made.name == "Inner" diff --git a/tests/test_syntax_control_flow.py b/tests/test_syntax_control_flow.py index 2c7d46d..ae951cf 100644 --- a/tests/test_syntax_control_flow.py +++ b/tests/test_syntax_control_flow.py @@ -68,7 +68,7 @@ def hir(text: str) -> nodes.Module: def lower(text: str, *, ssa: bool = False) -> FunctionIR: - return lower_module(hir(text), ssa=ssa).functions[0] + return next(f for f in lower_module(hir(text), ssa=ssa).functions if f.name != "") def printed(text: str) -> str: @@ -222,7 +222,7 @@ def test_clean_comprehensions_stay_clean() -> None: def test_assignments_to_global_names_lower_to_set_global() -> None: text = printed("counter = 0\n\ndef f():\n global counter\n counter = 1\n counter += 1\n") - assert text.count('set_global "counter"') == 2 + assert text.count('set_global "counter"') == 2 # the module body's ``counter = 0`` is its own local (store, _) = [i for b in lower("counter = 0\n\ndef f():\n global counter\n counter = 1\n counter += 1\n").blocks for i in b.instructions if isinstance(i, SetGlobal)] assert store.name == "counter" assert notes("counter = 0\n\ndef f():\n global counter\n counter = input()\n") == [] diff --git a/tests/test_syntax_coverage.py b/tests/test_syntax_coverage.py index c6c1da7..f5506fb 100644 --- a/tests/test_syntax_coverage.py +++ b/tests/test_syntax_coverage.py @@ -248,7 +248,9 @@ def test_emit_ir_module_level_code_and_methods(tmp_path, capsys) -> None: # typ tmp_path, capsys, ) - assert output.startswith("func @Store.get(%0, %1) {\n") + assert output.startswith("func @() {\n") + assert "func @Store.() {\n" in output + assert "func @Store.get(%0, %1) {\n" in output assert "func @top() {\n" in output @@ -280,7 +282,7 @@ def test_check_reports_unsupported_functions_and_keeps_going(tmp_path, capsys) - assert "mixed.py:3:1: info unsupported-syntax: " in output assert "break" in output assert "mixed.py:8:9: high dangerous-eval:" in output - assert output.endswith("2 findings\ncoverage: 1/1 files, 1/2 functions\n") + assert output.endswith("2 findings\ncoverage: 1/1 files, 2/3 functions\n") def test_unsupported_syntax_findings_are_notes() -> None: From 7228bb1394404cede07a32d28369174cbccc28a2 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 23 Sep 2026 07:52:21 +0200 Subject: [PATCH 2/3] feat: analyse module bodies and class bodies as functions The module's statements outside its definitions are lowered as a function named , and a top-level class's statements outside its methods as Cls., both carrying the span of what they stand for so their names resolve in the right scope. Their calls are call sites, their values carry taint and an advisory entry point called at import time is reachable. Inside the module body, the variables the module assigns are the body's locals: x = input(); os.system(x) connects in SSA and a module-level for loop has a target, while functions, classes and imports stay globals for the call graph and the symbol resolution. Wildcard imports lower to nothing. Coverage counts the bodies; the cache format is bumped. Regression snapshots re-recorded: 13 missing-timeout, 2 ssrf, 1 command-injection and 1 reachable-vulnerability at module level, and one stale syntax-error entry main no longer produced. Closes #113 --- CHANGELOG.md | 4 ++ docs/usage.md | 5 +- src/coretrace_python/cache.py | 2 +- .../interprocedural/callgraph.py | 11 +++- .../interprocedural/modulegraph.py | 5 +- src/coretrace_python/ir/lowering.py | 60 ++++++++++++++++++- src/coretrace_python/plugins/api.py | 3 +- src/coretrace_python/semantic/scopes.py | 4 +- src/coretrace_python/taint/engine.py | 11 +++- 9 files changed, 93 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf8ca9..84b9d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Analysis + +- The module body and the bodies of top-level classes are analysed as the functions `` and `Cls.`: their calls are call sites, their values carry taint, and an advisory entry point called at import time or in a class body is reachable. Coverage counts them; the cache format is bumped so cached results gain their findings (#113). + ### Dependencies - Advisories distinguish the `affected_symbols` a fix changed from the public `entry_points` through which they are reached, each justified and carrying `conditions` (checkable argument values, or `semantic` ones kept as pending review); `modules` names what the package installs. Every dependency finding records its evidence `level`: `declared`, `imported`, `reachable` or `exploitable`. diff --git a/docs/usage.md b/docs/usage.md index 68cef3f..61d2676 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -71,7 +71,10 @@ its package (`app/views.py` is `app.views`). Hidden directories, `node_modules`, dependency graph. Configuration files under the root (`.env`, YAML, TOML, JSON, INI, properties) are scanned for secrets. -Functions and methods are analysed; module-level statements outside functions are not. +Functions and methods are analysed, and so are the module body and the bodies of +top-level classes, as the functions `` and `Cls.`: `application = +get_asgi_application()` in `asgi.py` or `email = forms.EmailField()` in a form class are +calls the analyzer sees, and a module-level `os.system(input())` is a finding in ``. A function using syntax outside the supported subset is reported as an `unsupported-syntax` note and the other functions are still analysed; a file Python itself cannot parse is reported as a `syntax-error`. The coverage line and the JSON diff --git a/src/coretrace_python/cache.py b/src/coretrace_python/cache.py index c2ec61a..0f43b4f 100644 --- a/src/coretrace_python/cache.py +++ b/src/coretrace_python/cache.py @@ -37,7 +37,7 @@ from coretrace_python.semantic.symbols import SymbolId from coretrace_python.source import SourceId, SourceSpan -CACHE_FORMAT = 4 +CACHE_FORMAT = 5 @dataclass(frozen=True) diff --git a/src/coretrace_python/interprocedural/callgraph.py b/src/coretrace_python/interprocedural/callgraph.py index 4460fb9..f39ced5 100644 --- a/src/coretrace_python/interprocedural/callgraph.py +++ b/src/coretrace_python/interprocedural/callgraph.py @@ -16,7 +16,12 @@ from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis from coretrace_python.cfg import CFGError from coretrace_python.hir import nodes -from coretrace_python.ir.lowering import LoweringError, analyzable_functions, qualified_name +from coretrace_python.ir.lowering import ( + MODULE_BODY, + LoweringError, + analyzable_functions, + qualified_name, +) from coretrace_python.ir.model import ( Await, Call, @@ -198,7 +203,9 @@ def resolve_targets( for block in function.blocks: for instruction in block.instructions: if isinstance(instruction, MakeFunction): - qualified = f"{function.name}.{instruction.name}" + # A lambda or def made by the module body is named without a prefix, as + # its scope's parent is the module. + qualified = instruction.name if function.name == MODULE_BODY else f"{function.name}.{instruction.name}" if qualified in nested: targets[instruction.result] = KnownFunction(qualified) elif isinstance(instruction, Global): diff --git a/src/coretrace_python/interprocedural/modulegraph.py b/src/coretrace_python/interprocedural/modulegraph.py index 2360369..00dedfd 100644 --- a/src/coretrace_python/interprocedural/modulegraph.py +++ b/src/coretrace_python/interprocedural/modulegraph.py @@ -73,7 +73,10 @@ def project_symbol(module_name: str, qualified_name: str) -> SymbolId: symbols: invalid characters become underscores.""" module = ".".join(_component(part) for part in module_name.split(".")) - return SymbolId(f"python.{module}.{qualified_name}") + # A synthetic body function is ```` or ``Cls.``: valid as a name, not + # as a symbol component. Nothing calls a body, so its symbol only needs to be one. + qualified = ".".join(_component(part) for part in qualified_name.split(".")) + return SymbolId(f"python.{module}.{qualified}") def _component(part: str) -> str: diff --git a/src/coretrace_python/ir/lowering.py b/src/coretrace_python/ir/lowering.py index d99a938..3899fa9 100644 --- a/src/coretrace_python/ir/lowering.py +++ b/src/coretrace_python/ir/lowering.py @@ -120,7 +120,19 @@ def emit_effect(self, instruction: EffectInstruction) -> None: def resolve(self, name: str) -> Resolution: if name in self.cfg.synthetic_locals: return Resolution(ResolutionKind.LOCAL, self.scope.id) - return self.scopes.resolve(self.scope.id, name) + resolution = self.scopes.resolve(self.scope.id, name) + # The module body runs as a script: the variables it assigns are its locals, so + # ``x = input(); os.system(x)`` connects in SSA and ``for x in ...`` has a target. + # Its functions, classes and imports stay globals, which is how the call graph + # and the symbol resolution find them. + if ( + resolution.kind is ResolutionKind.GLOBAL + and self.scope.kind is ScopeKind.MODULE + and (binding := self.scope.bindings.get(name)) is not None + and binding.kind is BindingKind.LOCAL + ): + return Resolution(ResolutionKind.LOCAL, self.scope.id) + return resolution # ------------------------------------------------------------------ expressions @@ -360,6 +372,9 @@ def statement(self, node: nodes.Statement) -> None: # records that the import runs here (§39 rule 3). module = "." * node.level + (node.module or "") if isinstance(node, nodes.ImportFrom) else "" for alias in node.names: + if alias.name == "*": + # ``from m import *`` binds through the scope's wildcards, not a name. + continue bound = alias.as_name or alias.name.partition(".")[0] symbol = self.symbols.resolve(self.scope.id, bound) if symbol is None: @@ -563,8 +578,9 @@ def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> FunctionIR: def analyzable_functions(module: nodes.Module) -> tuple[nodes.Function, ...]: - """Top-level functions, the methods of top-level classes, and the functions and - lambdas nested inside them, in source order. Computed once per module: the result is + """The module body, top-level functions, top-level classes' bodies and methods, and + the functions and lambdas nested inside them, in source order. Computed once per + module: the result is memoised for the last few modules seen, keyed by identity, so every consumer shares one tuple and the synthesized lambda functions it holds.""" @@ -581,16 +597,54 @@ def analyzable_functions(module: nodes.Module) -> tuple[nodes.Function, ...]: def _analyzable_functions(module: nodes.Module) -> tuple[nodes.Function, ...]: functions: list[nodes.Function] = [] + body = module_function(module) + if body is not None: + _collect(body, functions) for statement in module.body: if isinstance(statement, nodes.Function): _collect(statement, functions) elif isinstance(statement, nodes.Class): + body = class_body_function(statement) + if body is not None: + _collect(body, functions) for member in statement.body: if isinstance(member, nodes.Function): _collect(member, functions) return tuple(functions) +MODULE_BODY = "" +CLASS_BODY = "." + + +def module_function(module: nodes.Module) -> nodes.Function | None: + """The module's statements outside its definitions, as a function named ```` + with the module's span, so its names resolve in the module scope; ``None`` when the + module only defines things.""" + + statements = tuple(s for s in module.body if not isinstance(s, nodes.Function | nodes.Class)) + if not statements: + return None + return nodes.Function(MODULE_BODY, (), statements, False, module.span) + + +def class_body_function(cls: nodes.Class) -> nodes.Function | None: + """A top-level class's statements outside its methods (``field = Field()``), as a + function named ``Cls.`` with the class's span, so its names resolve in the + class scope.""" + + statements = tuple(s for s in cls.body if not isinstance(s, nodes.Function)) + if not statements: + return None + return nodes.Function(f"{cls.name}{CLASS_BODY}", (), statements, False, cls.span) + + +def is_body_function(function: nodes.Function) -> bool: + """Whether ``function`` is a synthetic module or class body rather than a definition.""" + + return function.name == MODULE_BODY or function.name.endswith(CLASS_BODY) + + def _collect(function: nodes.Function, into: list[nodes.Function]) -> None: into.append(function) diff --git a/src/coretrace_python/plugins/api.py b/src/coretrace_python/plugins/api.py index b4e3b79..22078f4 100644 --- a/src/coretrace_python/plugins/api.py +++ b/src/coretrace_python/plugins/api.py @@ -163,7 +163,8 @@ def module(self) -> nodes.Module: return self._manager.module def functions(self) -> tuple[nodes.Function, ...]: - """Top-level functions and methods the engine can analyse.""" + """The functions the engine analyses: the module body (````), top-level + functions, class bodies (``Cls.``) and methods.""" return self._functions diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 877c53f..9f4a3b2 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -218,7 +218,9 @@ def __init__(self, module: nodes.Module) -> None: ScopeId(str(module.span.source_id)), ScopeKind.MODULE, "", None, module.span ) self.builders: list[_ScopeBuilder] = [self.module] - self.spans: dict[SourceSpan, ScopeId] = {} + # The module scope is found by the module's span, as the synthetic ```` + # function carries it. + self.spans: dict[SourceSpan, ScopeId] = {module.span: self.module.id} self.body(module.body, self.module) def body(self, statements: tuple[nodes.Statement, ...], scope: _ScopeBuilder) -> None: diff --git a/src/coretrace_python/taint/engine.py b/src/coretrace_python/taint/engine.py index f3ca52f..caeb6c9 100644 --- a/src/coretrace_python/taint/engine.py +++ b/src/coretrace_python/taint/engine.py @@ -38,7 +38,7 @@ SummaryTable, project_symbol, ) -from coretrace_python.ir.lowering import analyzable_functions +from coretrace_python.ir.lowering import analyzable_functions, is_body_function from coretrace_python.ir.model import ( BasicBlock, Branch, @@ -593,7 +593,12 @@ def local_instances(function: nodes.Function, scopes: ScopeTable, symbols: Symbo def _enclosing(module: nodes.Module, function: nodes.Function) -> nodes.Function | None: - """The function whose body defines ``function``, if it is nested.""" + """The function whose body defines ``function``, if it is nested. A module or class + body is nested in nothing, and encloses nothing: a lambda at module level reads + globals, not the locals of a function.""" + + if is_body_function(function): + return None def search( body: tuple[nodes.Statement, ...], parent: nodes.Function | None @@ -618,6 +623,8 @@ def search( # analysable function whose span contains theirs. innermost: nodes.Function | None = None for candidate in analyzable_functions(module): + if is_body_function(candidate): + continue if candidate.span != function.span and _contains(candidate.span, function.span): innermost = candidate return innermost From 09732f456567e3869a2f97a78b7ed1ca1f055ac7 Mon Sep 17 00:00:00 2001 From: Hugo Date: Wed, 23 Sep 2026 07:58:21 +0200 Subject: [PATCH 3/3] test: record the regression snapshots under Python 3.12, the CI interpreter full-stack-fastapi-template's deps.py uses 'except A, B:' (PEP 758), which only Python 3.14 parses; the snapshot keeps its syntax-error note and the coverage the CI observes. --- .../expected/full-stack-fastapi-template.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/regression/expected/full-stack-fastapi-template.json b/tests/regression/expected/full-stack-fastapi-template.json index 3b98cd5..ac779dc 100644 --- a/tests/regression/expected/full-stack-fastapi-template.json +++ b/tests/regression/expected/full-stack-fastapi-template.json @@ -1,9 +1,9 @@ { "coverage": { "files": 43, - "files_analysed": 43, - "functions": 198, - "functions_analysed": 198 + "files_analysed": 42, + "functions": 194, + "functions_analysed": 194 }, "findings": [ { @@ -30,6 +30,12 @@ "rule": "hardcoded-secret", "function": null }, + { + "file": "backend/app/api/deps.py", + "line": 1, + "rule": "syntax-error", + "function": null + }, { "file": "backend/app/api/routes/login.py", "line": 121,