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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ repeated. The repository ships a first set under `plugins/`: security models for
standard library, Flask, FastAPI, Django, SQLAlchemy and the Requests and httpx clients (`models/`), taint detectors for SQL
injection, command injection, path traversal, SSRF and XSS (`security/`), and syntactic
detectors for `eval`/`exec` and weak hashes (`syntax/`). Route handlers of Flask and
FastAPI receive their parameters as HTTP input; `request.args` and its siblings are HTTP
FastAPI receive their parameters as HTTP input, whether the application is created
directly (`app = Flask(__name__)`) or by a project factory whose summary returns it
(`app = create_app()`); `request.args` and its siblings are HTTP
sources. Django views are undecorated, so a parameter annotated with a request class
(`request: HttpRequest`), a method of a class-based view and a view decorated by one of
Django's or Django REST framework's view decorators receive HTTP input; a bare
Expand Down Expand Up @@ -145,8 +147,10 @@ with name or tuple targets, `break`, `continue` and `raise`, `from` clause inclu
conditional expressions and comprehensions, laid out as real branches and loops over a
synthetic local, set literals, chained assignments, assignments to `global` names, and
lambdas and nested function definitions as function values whose bodies are not analysed
yet, and `del`. Not yet: `match`, `nonlocal` assignments, classes defined inside functions,
and a conditional expression or comprehension inside a `while` condition. Blocks inside a `try`
yet, `del`, loop `else` clauses, and `match` over literal, singleton, capture, wildcard
and or-patterns with guards, laid out as an `if` chain. Not yet: sequence, mapping and
class patterns, `nonlocal` assignments, classes defined inside functions, and a
conditional expression or comprehension inside a `while` condition. Blocks inside a `try`
body carry exception edges to the handlers; `finally` is modelled on the normal path only. Methods of module-level classes are analysed like functions; other module-level
code is skipped. An import inside a function is shown where it runs, as an `import`
instruction naming the module, the bound name and the canonical symbol. A function using syntax outside this subset is reported by `--check` as
Expand Down
53 changes: 50 additions & 3 deletions src/coretrace_python/cfg/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ def statement(
return self.conditional(node, block, continuation)
if isinstance(node, nodes.While | nodes.For):
return self.loop_statement(node, block, continuation)
if isinstance(node, nodes.Match):
return self.match_statement(node, block, continuation)
if isinstance(node, nodes.With):
return self.with_statement(node, block)
if isinstance(node, nodes.Try):
Expand Down Expand Up @@ -308,18 +310,63 @@ def loop_statement(
) -> _Open | None:
header_id = self.new_id("loop")
body_id = self.new_id("body")
exit_id = continuation if continuation is not None else self.new_id("exit")
after_id = continuation if continuation is not None else self.new_id("exit")
# An ``else`` clause runs when the loop is exhausted; ``break`` skips it.
exit_id = self.new_id("else") if node.orelse else after_id
self.finish(block, Jump(header_id, node.span))
if isinstance(node, nodes.While):
self.header(header_id, Branch(node.condition, body_id, exit_id, node.span))
else:
self.header(header_id, ForEach(node.target, node.iterable, body_id, exit_id, node.span))
self.loops.append((header_id, exit_id))
self.loops.append((header_id, after_id))
try:
self.sequence(node.body, _Open(body_id), header_id, node.span)
finally:
self.loops.pop()
return None if continuation is not None else _Open(exit_id)
if node.orelse:
self.sequence(node.orelse, _Open(exit_id), after_id, node.span)
return None if continuation is not None else _Open(after_id)

def match_statement(
self, node: nodes.Match, block: _Open, continuation: BlockId | None
) -> _Open | None:
"""``match`` as an ``if`` chain over a hidden subject: literal, singleton, capture,
wildcard and or-patterns, with guards; other patterns are reported."""

subject = self.hidden("match", node.span)
statements: tuple[nodes.Statement, ...] = ()
for case in reversed(node.cases):
condition, bindings = self.pattern(case.pattern, subject)
if case.guard is not None:
condition = nodes.BoolOp("and", (condition, case.guard), case.span)
statements = (*bindings, nodes.If(condition, case.body, statements, case.span))
chain = (nodes.Assign(subject, node.subject, node.span), *statements)
return self.sequence(chain, block, continuation, node.span)

def pattern(self, node: nodes.Pattern, subject: nodes.Name) -> tuple[nodes.Expression, tuple[nodes.Statement, ...]]:
"""The condition a pattern tests on ``subject`` and the names it binds."""

if isinstance(node, nodes.ValuePattern):
return nodes.Compare("eq", subject, node.value, node.span), ()
if isinstance(node, nodes.SingletonPattern):
return nodes.Compare("is", subject, nodes.Constant(node.value, node.span), node.span), ()
if isinstance(node, nodes.WildcardPattern):
return nodes.Constant(True, node.span), ()
if isinstance(node, nodes.CapturePattern):
binding = nodes.Assign(nodes.Name(node.name, node.span), subject, node.span)
if node.pattern is None:
return nodes.Constant(True, node.span), (binding,)
condition, inner = self.pattern(node.pattern, subject)
return condition, (*inner, binding)
if isinstance(node, nodes.OrPattern):
conditions: list[nodes.Expression] = []
bindings: list[nodes.Statement] = []
for alternative in node.alternatives:
condition, inner = self.pattern(alternative, subject)
conditions.append(condition)
bindings.extend(inner)
return nodes.BoolOp("or", tuple(conditions), node.span), tuple(bindings)
raise CFGError(f"{node.span.display()}: match pattern {node.kind} is not supported yet")

def loop(self, keyword: str, span: SourceSpan) -> tuple[BlockId, BlockId]:
if not self.loops:
Expand Down
33 changes: 28 additions & 5 deletions src/coretrace_python/frontend/ast_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,8 @@ def statement(self, node: ast.stmt) -> nodes.Statement:
self.expression(node.test), self.block(node.body), self.block(node.orelse), span
)
if isinstance(node, ast.While):
if node.orelse:
self.fail(node.orelse[0], "loop else clauses are not supported yet")
return nodes.While(self.expression(node.test), self.block(node.body), span)
return nodes.While(self.expression(node.test), self.block(node.body), span, self.block(node.orelse))
if isinstance(node, (ast.For, ast.AsyncFor)):
if node.orelse:
self.fail(node.orelse[0], "loop else clauses are not supported yet")
body = self.block(node.body)
if isinstance(node.target, ast.Name):
target = nodes.Name(node.target.id, self.span(node.target))
Expand All @@ -290,11 +286,23 @@ def statement(self, node: ast.stmt) -> nodes.Statement:
body,
isinstance(node, ast.AsyncFor),
span,
self.block(node.orelse),
)
if isinstance(node, ast.Break):
return nodes.Break(span)
if isinstance(node, ast.Delete):
return nodes.Delete(tuple(self.target(target) for target in node.targets), span)
if isinstance(node, ast.Match):
cases = tuple(
nodes.MatchCase(
self.pattern(case.pattern),
self.expression(case.guard) if case.guard is not None else None,
self.block(case.body),
self.span(case.pattern),
)
for case in node.cases
)
return nodes.Match(self.expression(node.subject), cases, span)
if isinstance(node, ast.Continue):
return nodes.Continue(span)
if isinstance(node, ast.Raise):
Expand Down Expand Up @@ -329,6 +337,21 @@ def statement(self, node: ast.stmt) -> nodes.Statement:
return self.class_definition(node)
self.fail(node)

def pattern(self, node: ast.pattern) -> nodes.Pattern:
span = self.span(node)
if isinstance(node, ast.MatchValue):
return nodes.ValuePattern(self.expression(node.value), span)
if isinstance(node, ast.MatchSingleton):
return nodes.SingletonPattern(node.value, span)
if isinstance(node, ast.MatchAs):
if node.name is None:
return nodes.WildcardPattern(span)
inner = self.pattern(node.pattern) if node.pattern is not None else None
return nodes.CapturePattern(node.name, inner, span)
if isinstance(node, ast.MatchOr):
return nodes.OrPattern(tuple(self.pattern(p) for p in node.patterns), span)
return nodes.UnsupportedPattern(type(node).__name__, span)

def block(self, statements: list[ast.stmt]) -> tuple[nodes.Statement, ...]:
return tuple(found for statement in statements for found in self.statements(statement))

Expand Down
69 changes: 69 additions & 0 deletions src/coretrace_python/hir/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,72 @@ class Delete:
span: SourceSpan


@dataclass(frozen=True, slots=True)
class ValuePattern:
"""``case <expression>``: equality with a value."""

value: Expression
span: SourceSpan


@dataclass(frozen=True, slots=True)
class SingletonPattern:
"""``case None`` / ``True`` / ``False``: identity with a singleton."""

value: object
span: SourceSpan


@dataclass(frozen=True, slots=True)
class WildcardPattern:
span: SourceSpan


@dataclass(frozen=True, slots=True)
class CapturePattern:
"""``case name`` or ``case <pattern> as name``: binds the subject."""

name: str
pattern: Pattern | None
span: SourceSpan


@dataclass(frozen=True, slots=True)
class OrPattern:
alternatives: tuple[Pattern, ...]
span: SourceSpan


@dataclass(frozen=True, slots=True)
class UnsupportedPattern:
"""A sequence, mapping, class or star pattern; the CFG builder reports it."""

kind: str
span: SourceSpan


Pattern: TypeAlias = (
ValuePattern | SingletonPattern | WildcardPattern | CapturePattern | OrPattern | UnsupportedPattern
)


@dataclass(frozen=True, slots=True)
class MatchCase:
pattern: Pattern
guard: Expression | None
body: tuple[Statement, ...]
span: SourceSpan


@dataclass(frozen=True, slots=True)
class Match:
"""``match subject:``; the CFG builder lays it out as an ``if`` chain."""

subject: Expression
cases: tuple[MatchCase, ...]
span: SourceSpan


@dataclass(frozen=True, slots=True)
class Assert:
test: Expression
Expand All @@ -286,6 +352,7 @@ class While:
condition: Expression
body: tuple[Statement, ...]
span: SourceSpan
orelse: tuple[Statement, ...] = ()


@dataclass(frozen=True, slots=True)
Expand All @@ -295,6 +362,7 @@ class For:
body: tuple[Statement, ...]
is_async: bool
span: SourceSpan
orelse: tuple[Statement, ...] = ()


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -431,6 +499,7 @@ class Class:
| ExpressionStatement
| Pass
| Delete
| Match
| Assert
| If
| While
Expand Down
24 changes: 20 additions & 4 deletions src/coretrace_python/semantic/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,17 +293,22 @@ def statement(self, node: nodes.Statement, scope: _ScopeBuilder) -> None:
self.expression(base, scope)
scope.bind(node.name, BindingKind.CLASS, node.span)
self.body(node.body, self.open(scope, ScopeKind.CLASS, node.name, node.span))
elif isinstance(node, nodes.If):
elif isinstance(node, nodes.If | nodes.While):
self.expression(node.condition, scope)
self.body(node.body, scope)
self.body(node.orelse, scope)
elif isinstance(node, nodes.While):
self.expression(node.condition, scope)
self.body(node.body, scope)
elif isinstance(node, nodes.For):
self.expression(node.iterable, scope)
scope.bind(node.target.identifier, BindingKind.LOCAL, node.target.span)
self.body(node.body, scope)
self.body(node.orelse, scope)
elif isinstance(node, nodes.Match):
self.expression(node.subject, scope)
for case in node.cases:
self.pattern(case.pattern, scope)
if case.guard is not None:
self.expression(case.guard, scope)
self.body(case.body, scope)
elif isinstance(node, nodes.Raise):
if node.exception is not None:
self.expression(node.exception, scope)
Expand Down Expand Up @@ -379,6 +384,17 @@ def expression(self, node: nodes.Expression, scope: _ScopeBuilder) -> None:
else:
raise TypeError(f"unknown expression: {node!r}")

def pattern(self, node: nodes.Pattern, scope: _ScopeBuilder) -> None:
if isinstance(node, nodes.ValuePattern):
self.expression(node.value, scope)
elif isinstance(node, nodes.CapturePattern):
scope.bind(node.name, BindingKind.LOCAL, node.span)
if node.pattern is not None:
self.pattern(node.pattern, scope)
elif isinstance(node, nodes.OrPattern):
for alternative in node.alternatives:
self.pattern(alternative, scope)

def target(self, node: nodes.Target, scope: _ScopeBuilder) -> None:
if isinstance(node, nodes.Name):
scope.bind(node.identifier, BindingKind.LOCAL, node.span)
Expand Down
Loading
Loading