Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Analysis

- The module body and the bodies of top-level classes are analysed as the functions `<module>` and `Cls.<body>`: 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`.
Expand Down
5 changes: 4 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<module>` and `Cls.<body>`: `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 `<module>`.
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
Expand Down
2 changes: 1 addition & 1 deletion src/coretrace_python/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions src/coretrace_python/interprocedural/callgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion src/coretrace_python/interprocedural/modulegraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<module>`` or ``Cls.<body>``: 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:
Expand Down
60 changes: 57 additions & 3 deletions src/coretrace_python/ir/lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""

Expand All @@ -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 = "<module>"
CLASS_BODY = ".<body>"


def module_function(module: nodes.Module) -> nodes.Function | None:
"""The module's statements outside its definitions, as a function named ``<module>``
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.<body>`` 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)

Expand Down
3 changes: 2 additions & 1 deletion src/coretrace_python/plugins/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (``<module>``), top-level
functions, class bodies (``Cls.<body>``) and methods."""

return self._functions

Expand Down
4 changes: 3 additions & 1 deletion src/coretrace_python/semantic/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ def __init__(self, module: nodes.Module) -> None:
ScopeId(str(module.span.source_id)), ScopeKind.MODULE, "<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 ``<module>``
# 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:
Expand Down
11 changes: 9 additions & 2 deletions src/coretrace_python/taint/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/regression/expected/Chatbot.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"coverage": {
"files": 16,
"files_analysed": 16,
"functions": 176,
"functions_analysed": 176
"functions": 191,
"functions_analysed": 191
},
"findings": [
{
Expand Down
4 changes: 2 additions & 2 deletions tests/regression/expected/DjangoGoat.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"coverage": {
"files": 29,
"files_analysed": 29,
"functions": 39,
"functions_analysed": 39
"functions": 71,
"functions_analysed": 71
},
"findings": [
{
Expand Down
4 changes: 2 additions & 2 deletions tests/regression/expected/Flask-Image-Editing-Website.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"coverage": {
"files": 1,
"files_analysed": 1,
"functions": 4,
"functions_analysed": 4
"functions": 5,
"functions_analysed": 5
},
"findings": [
{
Expand Down
4 changes: 2 additions & 2 deletions tests/regression/expected/InsecureGram.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"coverage": {
"files": 15,
"files_analysed": 15,
"functions": 15,
"functions_analysed": 15
"functions": 34,
"functions_analysed": 34
},
"findings": [
{
Expand Down
4 changes: 2 additions & 2 deletions tests/regression/expected/MiniBookApiServer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"coverage": {
"files": 6,
"files_analysed": 6,
"functions": 40,
"functions_analysed": 40
"functions": 49,
"functions_analysed": 49
},
"findings": [
{
Expand Down
4 changes: 2 additions & 2 deletions tests/regression/expected/NPL-IA.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"coverage": {
"files": 1,
"files_analysed": 1,
"functions": 9,
"functions_analysed": 9
"functions": 10,
"functions_analysed": 10
},
"findings": [
{
Expand Down
4 changes: 2 additions & 2 deletions tests/regression/expected/Spam-Detection-Project.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"coverage": {
"files": 1,
"files_analysed": 1,
"functions": 2,
"functions_analysed": 2
"functions": 3,
"functions_analysed": 3
},
"findings": [
{
Expand Down
Loading
Loading