Python: model exception edges for raise-prone expressions inside try/with

The new CFG previously only emitted exception edges for explicit `raise`
and `assert` statements. As a result, code that became reachable only
via the exception path of an arbitrary expression (e.g., the body of an
`except` handler following a try-body whose `call()` could raise) was
classified as dead, breaking analyses like StackTraceExposure,
FileNotAlwaysClosed, ExceptionInfo, UseOfExit, and CatchingBaseException.

This commit adds a `mayThrow` predicate over expressions that are known
sources of implicit exceptions in Python (calls, attribute access,
subscripts, arithmetic/comparison operators, imports, await/yield/yield
from) plus `from m import *` at the statement level, and routes them
through the shared CFG's `beginAbruptCompletion(_, _, ExceptionSuccessor,
always=false)` hook.

The set of exception sources is restricted to nodes that are
syntactically inside a `try`/`with` statement in the same scope.
This mirrors Java's `ControlFlowGraph::mayThrow`, which only emits
exception edges where local handling can observe them — outside such
contexts, the edges add CFG complexity (weakening BarrierGuard
precision and breaking SSA continuity around augmented assignments and
subscript stores) without analysis benefit, since exceptions just
propagate to the function exit anyway.

Net effect on the test suite: ~100 alerts restored across the exception-
related query tests (StackTraceExposure +29, ExceptionInfo +17,
FileNotAlwaysClosed +52, UseOfExit +1, CatchingBaseException restored)
with no precision regressions. Affected `.expected` files and the
regression-guard `dead_under_no_raise.py` are updated accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
yoff
2026-06-03 09:46:03 +00:00
parent 956d2dbec4
commit 66900c7d62
2 changed files with 107 additions and 18 deletions

View File

@@ -1571,6 +1571,89 @@ private module Input implements InputSig1, InputSig2 {
private string assertThrowTag() { result = "[assert-throw]" }
/**
* Holds if the AST node `n` may raise an exception at runtime as part of
* its normal evaluation (not via an explicit `raise`/`assert`, which are
* modelled separately).
*
* The set mirrors what the legacy CFG used to flag implicitly: function
* calls (anything can raise), attribute access (`AttributeError`),
* subscript access (`IndexError`/`KeyError`/`TypeError`), arithmetic and
* comparison operators (`TypeError`/`ZeroDivisionError`), imports
* (`ImportError`/`ModuleNotFoundError`), and generator/coroutine
* suspension points (`await`/`yield`/`yield from`).
*
* Bare `Name` reads are intentionally excluded — modelling every name
* read as `mayThrow` would explode CFG edge count for negligible
* analysis value. `BoolExpr`/`IfExp` containers are also excluded; the
* operands they evaluate contribute their own exception edges.
*/
private predicate exprMayThrow(Py::Expr e) {
e instanceof Py::Call
or
e instanceof Py::Attribute
or
e instanceof Py::Subscript
or
e instanceof Py::BinaryExpr
or
e instanceof Py::UnaryExpr
or
e instanceof Py::Compare
or
e instanceof Py::ImportExpr
or
e instanceof Py::ImportMember
or
e instanceof Py::Await
or
e instanceof Py::Yield
or
e instanceof Py::YieldFrom
}
/**
* Holds if the statement `s` may raise an exception at runtime as part
* of its normal evaluation. Currently restricted to `from m import *`
* (which performs the import as a statement-level side effect).
*/
private predicate stmtMayThrow(Py::Stmt s) { s instanceof Py::ImportStar }
/**
* Holds if `n` is syntactically inside the body, handlers, `else`, or
* `finally` of a `try` statement (or the body of a `with` statement,
* which compiles to an implicit try/finally for `__exit__`) in the
* same scope.
*
* This mirrors Java's `ControlFlowGraph::mayThrow`, which only emits
* exception edges when there is local exception handling that would
* observe them. Outside such contexts, exception edges would add CFG
* complexity (weakening BarrierGuard precision and breaking SSA
* continuity around augmented assignments and subscript stores)
* without any analysis benefit, since exceptions just propagate to
* the function exit anyway.
*/
private predicate inExceptionContext(Py::AstNode py) {
exists(Py::Try t | t.containsInScope(py))
or
exists(Py::With w | w.containsInScope(py))
}
/**
* Holds if `n` may raise an exception during normal evaluation. See
* `exprMayThrow` and `stmtMayThrow` for the included AST classes.
*
* Restricted to nodes inside a `try`/`with` statement: matches Java's
* approach of only modelling exception flow where it can be observed
* by local handling.
*/
private predicate mayThrow(Ast::AstNode n) {
exists(Py::AstNode py | py = n.asExpr() or py = n.asStmt() |
(exprMayThrow(py) or stmtMayThrow(py)) and
inExceptionContext(py)
)
}
predicate additionalNode(Ast::AstNode n, string tag, NormalSuccessor t) {
n instanceof Ast::AssertStmt and tag = assertThrowTag() and t instanceof DirectSuccessor
}
@@ -1582,6 +1665,11 @@ private module Input implements InputSig1, InputSig2 {
n.isAdditional(ast, assertThrowTag()) and
c.asSimpleAbruptCompletion() instanceof ExceptionSuccessor and
always = true
or
mayThrow(ast) and
n.isIn(ast) and
c.asSimpleAbruptCompletion() instanceof ExceptionSuccessor and
always = false
}
predicate endAbruptCompletion(Ast::AstNode ast, PreControlFlowNode n, AbruptCompletion c) {