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 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..ac779dc 100644 --- a/tests/regression/expected/full-stack-fastapi-template.json +++ b/tests/regression/expected/full-stack-fastapi-template.json @@ -2,8 +2,8 @@ "coverage": { "files": 43, "files_analysed": 42, - "functions": 140, - "functions_analysed": 140 + "functions": 194, + "functions_analysed": 194 }, "findings": [ { 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: