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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ component per requirement with its package URL, and the advisories affecting the
vulnerabilities. The shipped `secrets/` plugin scans every string literal of the Python
sources for provider-specific secret formats (AWS, GitHub, Slack, Stripe, Google, private
keys, JWTs, SendGrid, Twilio), for credential-like names bound to a real value
(`password`, `token`, `api_key` and the like, placeholders excluded) and for opaque
(`password`, `token`, `api_key` and the like, bound by name, attribute or constant key
such as `app.config['SECRET_KEY']`, placeholders excluded) and for opaque
high-entropy tokens; one finding per literal, at high, medium and low confidence
respectively, with a redacted preview and never the secret itself. Other plugins add
providers by subclassing `SecretDetector` with their own patterns. Configuration files
Expand Down Expand Up @@ -144,8 +145,8 @@ 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. Not yet: `match`, `del`, `nonlocal` assignments, classes defined inside functions, and
a conditional expression or comprehension inside a `while` condition. Blocks inside a `try`
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`
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
3 changes: 3 additions & 0 deletions plugins/models/flask/flask_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ class FlaskModels(ModelPlugin):
Sink(_sym("flask.Response"), TaintKind.HTML),
Sink(_sym("flask.Markup"), TaintKind.HTML),
Sink(_sym("flask.send_file"), TaintKind.PATH),
Sink(_sym("flask.request.files.save"), TaintKind.PATH),
Sanitizer(_sym("werkzeug.utils.secure_filename"), TaintKind.PATH),
Sanitizer(_sym("flask.escape"), TaintKind.HTML),
Sanitizer(_sym("markupsafe.escape"), TaintKind.HTML),
AuthorizationGuard(_sym("flask_login.login_required"), "login"),
AuthorizationGuard(_sym("flask_login.fresh_login_required"), "login"),
AuthorizationGuard(_sym("flask_login.current_user.is_authenticated"), "login"),
AuthorizationGuard(_sym("flask.session"), "session"),
)
6 changes: 6 additions & 0 deletions src/coretrace_python/findings/refutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,12 @@ def authorized_by(self, condition: Value, truth: bool) -> AuthorizationGuard | N
if found is not None:
return found
return None
if isinstance(definition, Compare) and definition.operator in ("in", "not_in"):
# ``'logged_in' in session``: membership in an authorization store.
if truth != (definition.operator == "in"):
return None
container = self.symbols.get(definition.right)
return self.models.authorization(container) if container is not None else None
if not truth:
return None
symbol = self.symbols.get(condition)
Expand Down
2 changes: 2 additions & 0 deletions src/coretrace_python/frontend/ast_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ def statement(self, node: ast.stmt) -> nodes.Statement:
)
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.Continue):
return nodes.Continue(span)
if isinstance(node, ast.Raise):
Expand Down
7 changes: 7 additions & 0 deletions src/coretrace_python/hir/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ class Pass:
span: SourceSpan


@dataclass(frozen=True, slots=True)
class Delete:
targets: tuple[Target, ...]
span: SourceSpan


@dataclass(frozen=True, slots=True)
class Assert:
test: Expression
Expand Down Expand Up @@ -424,6 +430,7 @@ class Class:
| Return
| ExpressionStatement
| Pass
| Delete
| Assert
| If
| While
Expand Down
22 changes: 18 additions & 4 deletions src/coretrace_python/interprocedural/callgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@
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.model import Call, FunctionIR, GetAttr, Global, Symbol, Value, WithEnter
from coretrace_python.ir.model import (
Call,
FunctionIR,
GetAttr,
GetItem,
Global,
Symbol,
Value,
WithEnter,
)
from coretrace_python.ir.ssa import SSAAnalysis
from coretrace_python.semantic.scopes import BindingKind, ScopeAnalysis, ScopeTable
from coretrace_python.semantic.symbols import SymbolId
Expand Down Expand Up @@ -100,9 +109,10 @@ def callers(self, name: str) -> frozenset[str]:


def derive_symbols(function: FunctionIR) -> dict[Value, SymbolId]:
"""Symbols of values: ``Symbol`` results, attributes of symbol values, results of
calling a symbol (``sqlite3.connect(p)`` denotes ``python.sqlite3.connect``) and the
values a ``with`` on such a result binds. Known functions and parameters derive nothing."""
"""Symbols of values: ``Symbol`` results, attributes and items of symbol values,
results of calling a symbol (``sqlite3.connect(p)`` denotes ``python.sqlite3.connect``)
and the values a ``with`` on such a result binds. Known functions and parameters
derive nothing."""

symbols: dict[Value, SymbolId] = {}
changed = True
Expand All @@ -117,6 +127,10 @@ def derive_symbols(function: FunctionIR) -> dict[Value, SymbolId]:
symbol = instruction.symbol_id
elif isinstance(instruction, GetAttr) and instruction.object in symbols:
symbol = symbols[instruction.object].attribute(instruction.attribute)
elif isinstance(instruction, GetItem) and instruction.object in symbols:
# An item of a symbol-denoted container (``request.files['f']``)
# carries the container's symbol, so its methods resolve.
symbol = symbols[instruction.object]
elif isinstance(instruction, Call | WithEnter):
origin = (
instruction.callee if isinstance(instruction, Call) else instruction.context
Expand Down
11 changes: 11 additions & 0 deletions src/coretrace_python/ir/lowering.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
Catch,
Compare,
Constant,
DelAttr,
DelItem,
EffectInstruction,
ForNext,
FunctionIR,
Expand Down Expand Up @@ -237,6 +239,15 @@ def statement(self, node: nodes.Statement) -> None:
if isinstance(node, nodes.Assign):
self.store(node.target, self.expression(node.value))
return
if isinstance(node, nodes.Delete):
for target in node.targets:
if isinstance(target, nodes.Subscript):
obj, key = self.expression(target.value), self.expression(target.key)
self.emit_effect(DelItem(None, target.span, obj, key))
elif isinstance(target, nodes.Attribute):
self.emit_effect(DelAttr(None, target.span, self.expression(target.value), target.name))
# ``del name`` unbinds a local; the SSA form has no slot to clear.
return
if isinstance(node, nodes.Function):
# A nested definition is a value bound to its name; its body is its own
# scope and is not lowered here.
Expand Down
20 changes: 19 additions & 1 deletion src/coretrace_python/ir/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,22 @@ class Assert(Operands):
message: Value | None


@dataclass(frozen=True)
class DelItem(Operands):
result: None
location: SourceSpan
object: Value
key: Value


@dataclass(frozen=True)
class DelAttr(Operands):
result: None
location: SourceSpan
object: Value
attribute: str


@dataclass(frozen=True)
class SetGlobal(Operands):
"""Assignment to a name declared ``global``."""
Expand Down Expand Up @@ -360,7 +376,9 @@ class Import(Operands):
| Await
| Yield
)
EffectInstruction: TypeAlias = StoreLocal | SetAttr | SetItem | WithExit | Assert | Import | SetGlobal
EffectInstruction: TypeAlias = (
StoreLocal | SetAttr | SetItem | WithExit | Assert | Import | SetGlobal | DelItem | DelAttr
)
Instruction: TypeAlias = ValueInstruction | EffectInstruction


Expand Down
6 changes: 6 additions & 0 deletions src/coretrace_python/ir/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
Catch,
Compare,
Constant,
DelAttr,
DelItem,
ForNext,
GetAttr,
GetItem,
Expand Down Expand Up @@ -93,6 +95,10 @@ def _instruction(instruction: Instruction) -> str:
return f"{_value(instruction.result)} = build_set {', '.join(parts)}".rstrip()
if isinstance(instruction, MakeFunction):
return f'{_value(instruction.result)} = make_function "{instruction.name}"'
if isinstance(instruction, DelItem):
return f"del_item {_value(instruction.object)}, {_value(instruction.key)}"
if isinstance(instruction, DelAttr):
return f'del_attr {_value(instruction.object)}, "{instruction.attribute}"'
if isinstance(instruction, SetGlobal):
return f'set_global "{instruction.name}", {_value(instruction.value)}'
if isinstance(instruction, BuildString):
Expand Down
15 changes: 13 additions & 2 deletions src/coretrace_python/plugins/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ def _walk(node: Node, name: str | None, function: str | None) -> Iterator[Litera
yield from _walk(child, None, None)
return
if isinstance(node, nodes.Assign):
target = node.target.identifier if isinstance(node.target, nodes.Name) else None
yield from _walk(node.value, target, function)
yield from _walk(node.value, _bound_name(node.target), function)
return
if isinstance(node, nodes.Keyword):
yield from _walk(node.value, node.name, function)
Expand All @@ -87,6 +86,18 @@ def _walk(node: Node, name: str | None, function: str | None) -> Iterator[Litera
yield from _walk(child, None, function)


def _bound_name(target: nodes.Target) -> str | None:
"""The name an assignment binds: ``name``, ``obj.attr`` or ``mapping['key']``."""

if isinstance(target, nodes.Name):
return target.identifier
if isinstance(target, nodes.Attribute):
return target.name
if isinstance(target, nodes.Subscript) and isinstance(target.key, nodes.Constant):
return target.key.value if isinstance(target.key.value, str) else None
return None


@dataclass(frozen=True)
class SecretPattern:
"""A provider-specific secret format."""
Expand Down
3 changes: 3 additions & 0 deletions src/coretrace_python/semantic/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,9 @@ def statement(self, node: nodes.Statement, scope: _ScopeBuilder) -> None:
self.expression(node.exception, scope)
if node.cause is not None:
self.expression(node.cause, scope)
elif isinstance(node, nodes.Delete):
for target in node.targets:
self.target(target, scope)
elif not isinstance(node, nodes.Pass | nodes.Break | nodes.Continue):
raise TypeError(f"unknown statement: {node!r}")

Expand Down
Loading
Loading