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
24 changes: 23 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ variable is not treated that way yet.
| `exploitable-vulnerability` | An API affected by an advisory of a vulnerable requirement, see Dependencies. |

Each flow is judged before it is reported. A dominating guard that proves the value
safe (`isdigit()`, membership in a constant collection, equality with a constant, a
safe (`isdigit()`, membership in a constant allowlist, equality with a constant, a
numeric value proven by `int()`, `len()` or bounded arithmetic, a validator such as
`re.fullmatch`) refutes it and nothing is reported. A hotspot is reported at medium
confidence. Two things make a flow a hotspot:
Expand All @@ -149,6 +149,28 @@ a vulnerability. When the flow passes the object whole, to the sink or to a func
a guard on any of its attributes still counts. The verdict and its evidence are in the
finding's metadata.

A membership test (`in`, `not in`) is a constant allowlist when every member is a constant
that cannot change before the test:

- a tuple, list or set display written in the function, or a dict display by its keys,
tested directly or through `.keys()`;
- `frozenset`, `set`, `tuple` or `list` of one of these;
- a module-level tuple, or `frozenset` of a display, of constants, bound once in its
module and never rebound there, by another assignment, an augmented assignment or a
`global` statement.

A list, set or dict of the function, a display or the `set` or `list` of one, counts only
while the function only reads it: changed by a method, stored, or passed to a call before
the test, it may hold anything. A
module-level list, set or dict can be changed from any module, so it never counts, and
the guard leaves a hotspot. Rebinding a module-level name from another module
(`settings.ALLOWED = ...`) is not seen, as for the module-level strings that fix a URL.

A lookup in a dict display, `{"list": "ls"}[key]` or `.get(key, default)`, gives one of
its values, of what was stored in it since, or the default, never a key: input that only
selects the entry, or that is itself a key of the display, is not passed on. The same lookup in a module-level dict passes the key on, as any other
subscript does.

Django's `render_to_string(name, context)` returns HTML in which autoescaping escaped
every variable, so data reaching a response through it is no `xss`, if the template really
escapes. On a directory check, the analyzer reads the project's templates to decide it.
Expand Down
121 changes: 121 additions & 0 deletions src/coretrace_python/abstract/module_constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Module-level names whose value is known (architecture §18): the names bound once and
never rebound, and among them the ones bound to an immutable collection of constants,
which an allowlist reads."""

from __future__ import annotations

from collections.abc import Mapping
from types import MappingProxyType
from typing import ClassVar

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 (
MODULE_BODY,
LoweringError,
PyIRAnalysis,
analyzable_functions,
)
from coretrace_python.ir.model import (
BuildDict,
BuildList,
BuildSet,
BuildTuple,
Call,
Constant,
ForNext,
Instruction,
SetGlobal,
StoreLocal,
Symbol,
Value,
)
from coretrace_python.semantic.symbols import SymbolId

FROZENSET = SymbolId("python.builtins.frozenset")


def module_bindings(ctx: AnalysisContext) -> tuple[Mapping[str, Instruction], Mapping[Value, Instruction]]:
"""The module-level names bound once and never rebound, by no other statement of the
module body, no function assigning them as ``global``, no definition of that name,
each with the instruction that makes its value; and the definitions of the module
body's values. A module that does not lower entirely has none."""

try:
functions = [ctx.get(PyIRAnalysis, function) for function in analyzable_functions(ctx.module)]
except (LoweringError, CFGError):
return MappingProxyType({}), MappingProxyType({})
body = next((function for function in functions if function.name == MODULE_BODY), None)
if body is None:
return MappingProxyType({}), MappingProxyType({})
defs = {i.result: i for block in body.blocks for i in block.instructions if i.result is not None}
stored: dict[str, list[Value | None]] = {}
for block in body.blocks:
for instruction in block.instructions:
if isinstance(instruction, StoreLocal):
stored.setdefault(instruction.name, []).append(instruction.value)
if isinstance(block.terminator, ForNext):
# A ``for`` binds its target to each item.
stored.setdefault(block.terminator.target, []).append(None)
rebound = {
i.name for function in functions for block in function.blocks for i in block.instructions
if isinstance(i, SetGlobal)
}
rebound |= {s.name for s in ctx.module.body if isinstance(s, nodes.Function | nodes.Class)}
bound: dict[str, Instruction] = {}
for name, values in stored.items():
value = values[0] if len(values) == 1 and name not in rebound else None
made = defs.get(value) if value is not None else None
if made is not None:
bound[name] = made
return MappingProxyType(bound), MappingProxyType(defs)


def constant_members(display: Instruction | None, defs: Mapping[Value, Instruction]) -> bool:
"""Whether ``display`` is a tuple, list or set display whose items are all constants,
or a dict display whose keys are, with nothing unpacked into it: the members a
membership test reads."""

if isinstance(display, BuildList | BuildTuple | BuildSet):
return not display.unpacked and all(isinstance(defs.get(e), Constant) for e in display.elements)
if isinstance(display, BuildDict):
return not display.unpacked and all(isinstance(defs.get(key), Constant) for key, _ in display.items)
return False


def converts(call: Call, defs: Mapping[Value, Instruction], symbols: frozenset[SymbolId]) -> Value | None:
"""The one positional argument of ``call`` when it calls one of ``symbols`` with
nothing else (``frozenset(x)``), otherwise ``None``."""

callee = defs.get(call.callee)
if not isinstance(callee, Symbol) or callee.symbol_id not in symbols:
return None
if len(call.arguments) != 1 or call.keywords or call.starred:
return None
return call.arguments[0]


class ModuleCollectionsAnalysis(Analysis[frozenset[str]]):
"""The module-level names bound once and never rebound (``module_bindings``) to an
immutable collection of constants: a tuple display, or ``frozenset`` of a display
whose members are constants (``constant_members``). A list, a set or a dict can still
change after it is bound, so it is not one."""

name: ClassVar[str] = "abstract.module_collections"
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({PyIRAnalysis})

@classmethod
def compute(cls, ctx: AnalysisContext) -> frozenset[str]:
bound, defs = module_bindings(ctx)
found: set[str] = set()
for name, made in bound.items():
collection: Instruction | None = made
if isinstance(made, Call):
argument = converts(made, defs, frozenset({FROZENSET}))
collection = defs.get(argument) if argument is not None else None
elif not isinstance(made, BuildTuple):
continue
if constant_members(collection, defs):
found.add(name)
return frozenset(found)
54 changes: 12 additions & 42 deletions src/coretrace_python/abstract/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,15 @@
from types import MappingProxyType
from typing import ClassVar

from coretrace_python.abstract.module_constants import module_bindings
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 (
MODULE_BODY,
LoweringError,
PyIRAnalysis,
analyzable_functions,
)
from coretrace_python.ir.lowering import PyIRAnalysis
from coretrace_python.ir.model import (
BinaryOp,
BuildString,
Constant,
ForNext,
Global,
Instruction,
SetGlobal,
StoreLocal,
Value,
)

Expand Down Expand Up @@ -59,40 +50,19 @@ def leading_text(value: Value, defs: Mapping[Value, Instruction], strings: Mappi


class ModuleStringsAnalysis(Analysis[Mapping[str, str]]):
"""The module-level names bound once, to a string literal, and never rebound: by no
other statement of the module body, no function assigning them as ``global``, no
definition of that name. A module that does not lower entirely has none."""
"""The module-level names bound once and never rebound (``module_bindings``) to a
string literal, with that literal."""

name: ClassVar[str] = "abstract.module_strings"
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({PyIRAnalysis})

@classmethod
def compute(cls, ctx: AnalysisContext) -> Mapping[str, str]:
try:
functions = [ctx.get(PyIRAnalysis, function) for function in analyzable_functions(ctx.module)]
except (LoweringError, CFGError):
return MappingProxyType({})
body = next((function for function in functions if function.name == MODULE_BODY), None)
if body is None:
return MappingProxyType({})
defs = {i.result: i for block in body.blocks for i in block.instructions if i.result is not None}
stored: dict[str, list[Value | None]] = {}
for block in body.blocks:
for instruction in block.instructions:
if isinstance(instruction, StoreLocal):
stored.setdefault(instruction.name, []).append(instruction.value)
if isinstance(block.terminator, ForNext):
# A ``for`` binds its target to each item.
stored.setdefault(block.terminator.target, []).append(None)
rebound = {
i.name for function in functions for block in function.blocks for i in block.instructions
if isinstance(i, SetGlobal)
}
rebound |= {s.name for s in ctx.module.body if isinstance(s, nodes.Function | nodes.Class)}
found: dict[str, str] = {}
for name, values in stored.items():
value = values[0] if len(values) == 1 and name not in rebound else None
made = defs.get(value) if value is not None else None
if isinstance(made, Constant) and isinstance(made.value, str):
found[name] = made.value
return MappingProxyType(found)
bound, _ = module_bindings(ctx)
return MappingProxyType(
{
name: made.value
for name, made in bound.items()
if isinstance(made, Constant) and isinstance(made.value, str)
}
)
2 changes: 2 additions & 0 deletions src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from coretrace_python import __version__
from coretrace_python.abstract import ConstantPropagation, HeapAnalysis, RangeAnalysis
from coretrace_python.abstract.module_constants import ModuleCollectionsAnalysis
from coretrace_python.abstract.strings import ModuleStringsAnalysis
from coretrace_python.analysis import (
AnalysisContext,
Expand Down Expand Up @@ -158,6 +159,7 @@
ConstantPropagation,
RangeAnalysis,
ModuleStringsAnalysis,
ModuleCollectionsAnalysis,
HeapAnalysis,
CallGraphAnalysis,
SummaryAnalysis,
Expand Down
Loading
Loading