coretrace-python-analyzer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coretrace_python/__init__.py +4 -0
- coretrace_python/__main__.py +4 -0
- coretrace_python/abstract/__init__.py +45 -0
- coretrace_python/abstract/constants.py +226 -0
- coretrace_python/abstract/heap.py +252 -0
- coretrace_python/abstract/ranges.py +285 -0
- coretrace_python/abstract/values.py +56 -0
- coretrace_python/analysis/__init__.py +31 -0
- coretrace_python/analysis/manager.py +165 -0
- coretrace_python/analysis/provider.py +73 -0
- coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py +46 -0
- coretrace_python/bundled/dependency/dependency_policy/plugin.toml +9 -0
- coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml +9 -0
- coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +56 -0
- coretrace_python/bundled/dependency/sample_advisories/plugin.toml +9 -0
- coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +109 -0
- coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml +9 -0
- coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +43 -0
- coretrace_python/bundled/models/cli/cli_models.py +35 -0
- coretrace_python/bundled/models/cli/plugin.toml +9 -0
- coretrace_python/bundled/models/credentials/credential_models.py +43 -0
- coretrace_python/bundled/models/credentials/plugin.toml +9 -0
- coretrace_python/bundled/models/django/django_models.py +123 -0
- coretrace_python/bundled/models/django/plugin.toml +9 -0
- coretrace_python/bundled/models/fastapi/fastapi_models.py +29 -0
- coretrace_python/bundled/models/fastapi/plugin.toml +9 -0
- coretrace_python/bundled/models/flask/flask_models.py +53 -0
- coretrace_python/bundled/models/flask/plugin.toml +9 -0
- coretrace_python/bundled/models/http_clients/http_client_models.py +43 -0
- coretrace_python/bundled/models/http_clients/plugin.toml +9 -0
- coretrace_python/bundled/models/python_stdlib/plugin.toml +9 -0
- coretrace_python/bundled/models/python_stdlib/python_stdlib.py +68 -0
- coretrace_python/bundled/models/sqlalchemy/plugin.toml +9 -0
- coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py +47 -0
- coretrace_python/bundled/secrets/config_secrets/config_secrets.py +38 -0
- coretrace_python/bundled/secrets/config_secrets/plugin.toml +9 -0
- coretrace_python/bundled/secrets/hardcoded_secrets/hardcoded_secrets.py +19 -0
- coretrace_python/bundled/secrets/hardcoded_secrets/plugin.toml +9 -0
- coretrace_python/bundled/security/command_injection/command_injection.py +17 -0
- coretrace_python/bundled/security/command_injection/plugin.toml +9 -0
- coretrace_python/bundled/security/insecure_deserialization/insecure_deserialization.py +17 -0
- coretrace_python/bundled/security/insecure_deserialization/plugin.toml +9 -0
- coretrace_python/bundled/security/open_redirect/open_redirect.py +17 -0
- coretrace_python/bundled/security/open_redirect/plugin.toml +9 -0
- coretrace_python/bundled/security/path_traversal/path_traversal.py +17 -0
- coretrace_python/bundled/security/path_traversal/plugin.toml +9 -0
- coretrace_python/bundled/security/plaintext_credentials/plaintext_credentials.py +21 -0
- coretrace_python/bundled/security/plaintext_credentials/plugin.toml +9 -0
- coretrace_python/bundled/security/sql_injection/plugin.toml +9 -0
- coretrace_python/bundled/security/sql_injection/sql_injection.py +17 -0
- coretrace_python/bundled/security/ssrf/plugin.toml +9 -0
- coretrace_python/bundled/security/ssrf/ssrf.py +17 -0
- coretrace_python/bundled/security/xss/plugin.toml +9 -0
- coretrace_python/bundled/security/xss/xss.py +17 -0
- coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py +19 -0
- coretrace_python/bundled/syntax/dangerous_eval/plugin.toml +9 -0
- coretrace_python/bundled/syntax/flask_debug/flask_debug.py +63 -0
- coretrace_python/bundled/syntax/flask_debug/plugin.toml +9 -0
- coretrace_python/bundled/syntax/missing_timeout/missing_timeout.py +51 -0
- coretrace_python/bundled/syntax/missing_timeout/plugin.toml +9 -0
- coretrace_python/bundled/syntax/weak_crypto/plugin.toml +9 -0
- coretrace_python/bundled/syntax/weak_crypto/weak_crypto.py +19 -0
- coretrace_python/cache.py +310 -0
- coretrace_python/cfg/__init__.py +46 -0
- coretrace_python/cfg/builder.py +589 -0
- coretrace_python/cfg/dominance.py +183 -0
- coretrace_python/cfg/model.py +166 -0
- coretrace_python/cli.py +216 -0
- coretrace_python/dataflow/__init__.py +29 -0
- coretrace_python/dataflow/lattice.py +78 -0
- coretrace_python/dataflow/solver.py +96 -0
- coretrace_python/dependency/__init__.py +44 -0
- coretrace_python/dependency/advisories.py +168 -0
- coretrace_python/dependency/correlation.py +87 -0
- coretrace_python/dependency/graph.py +274 -0
- coretrace_python/dependency/policy.py +65 -0
- coretrace_python/dependency/sbom.py +65 -0
- coretrace_python/engine.py +688 -0
- coretrace_python/findings/__init__.py +13 -0
- coretrace_python/findings/coverage.py +45 -0
- coretrace_python/findings/model.py +49 -0
- coretrace_python/findings/refutation.py +427 -0
- coretrace_python/frontend/__init__.py +18 -0
- coretrace_python/frontend/ast_adapter.py +447 -0
- coretrace_python/frontend/parser.py +23 -0
- coretrace_python/hir/__init__.py +5 -0
- coretrace_python/hir/nodes.py +584 -0
- coretrace_python/hir/visitors.py +36 -0
- coretrace_python/interprocedural/__init__.py +49 -0
- coretrace_python/interprocedural/callgraph.py +291 -0
- coretrace_python/interprocedural/modulegraph.py +218 -0
- coretrace_python/interprocedural/summaries.py +463 -0
- coretrace_python/ir/__init__.py +5 -0
- coretrace_python/ir/defuse.py +74 -0
- coretrace_python/ir/lowering.py +575 -0
- coretrace_python/ir/model.py +481 -0
- coretrace_python/ir/printer.py +201 -0
- coretrace_python/ir/ssa.py +277 -0
- coretrace_python/plugins/__init__.py +58 -0
- coretrace_python/plugins/api.py +153 -0
- coretrace_python/plugins/detectors.py +114 -0
- coretrace_python/plugins/loader.py +84 -0
- coretrace_python/plugins/manifest.py +112 -0
- coretrace_python/plugins/registry.py +34 -0
- coretrace_python/plugins/secrets.py +330 -0
- coretrace_python/reporters/__init__.py +22 -0
- coretrace_python/reporters/json_format.py +48 -0
- coretrace_python/reporters/report.py +23 -0
- coretrace_python/reporters/sarif.py +70 -0
- coretrace_python/reporters/text.py +24 -0
- coretrace_python/semantic/__init__.py +9 -0
- coretrace_python/semantic/identity.py +39 -0
- coretrace_python/semantic/imports.py +131 -0
- coretrace_python/semantic/scopes.py +473 -0
- coretrace_python/semantic/symbols.py +87 -0
- coretrace_python/source/__init__.py +13 -0
- coretrace_python/source/manager.py +81 -0
- coretrace_python/source/model.py +57 -0
- coretrace_python/taint/__init__.py +55 -0
- coretrace_python/taint/engine.py +800 -0
- coretrace_python/taint/models.py +317 -0
- coretrace_python/taint/routes.py +99 -0
- coretrace_python_analyzer-0.1.0.dist-info/METADATA +74 -0
- coretrace_python_analyzer-0.1.0.dist-info/RECORD +126 -0
- coretrace_python_analyzer-0.1.0.dist-info/WHEEL +4 -0
- coretrace_python_analyzer-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Numeric range analysis (architecture §18, §24).
|
|
2
|
+
|
|
3
|
+
Every value proven numeric gets an ``Interval``: numeric constants, arithmetic on
|
|
4
|
+
numbers, the results of ``int()``, ``len()`` and friends. Comparisons refine the
|
|
5
|
+
intervals of both sides on the branch they take, chained comparisons and ``and`` / ``or``
|
|
6
|
+
included, and loops converge by widening. Values not proven numeric have no interval:
|
|
7
|
+
the domain doubles as a proof of numeric type, which is what the refutation engine
|
|
8
|
+
needs, since a number cannot carry an injection.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from types import MappingProxyType
|
|
16
|
+
from typing import ClassVar
|
|
17
|
+
|
|
18
|
+
from coretrace_python.analysis import AnalysisContext, AnyAnalysis, FunctionAnalysis
|
|
19
|
+
from coretrace_python.cfg import CFG, BlockId, CFGAnalysis
|
|
20
|
+
from coretrace_python.dataflow import DataflowProblem, Direction, solve
|
|
21
|
+
from coretrace_python.hir import nodes
|
|
22
|
+
from coretrace_python.ir.model import (
|
|
23
|
+
BasicBlock,
|
|
24
|
+
BinaryOp,
|
|
25
|
+
BoolOp,
|
|
26
|
+
Branch,
|
|
27
|
+
Call,
|
|
28
|
+
Compare,
|
|
29
|
+
Constant,
|
|
30
|
+
ForNext,
|
|
31
|
+
FunctionIR,
|
|
32
|
+
Instruction,
|
|
33
|
+
Jump,
|
|
34
|
+
Phi,
|
|
35
|
+
Symbol,
|
|
36
|
+
UnaryOp,
|
|
37
|
+
Value,
|
|
38
|
+
)
|
|
39
|
+
from coretrace_python.ir.ssa import SSAAnalysis
|
|
40
|
+
|
|
41
|
+
INF = float("inf")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _format(bound: float) -> str:
|
|
45
|
+
if bound in (INF, -INF):
|
|
46
|
+
return "inf" if bound > 0 else "-inf"
|
|
47
|
+
return str(int(bound)) if float(bound).is_integer() else str(bound)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Interval:
|
|
52
|
+
"""A closed range of numbers; ``inf`` bounds mean unbounded."""
|
|
53
|
+
|
|
54
|
+
low: float
|
|
55
|
+
high: float
|
|
56
|
+
|
|
57
|
+
def join(self, other: Interval) -> Interval:
|
|
58
|
+
return Interval(min(self.low, other.low), max(self.high, other.high))
|
|
59
|
+
|
|
60
|
+
def widen(self, other: Interval) -> Interval:
|
|
61
|
+
"""``self`` extended to ``other``, jumping to infinity on any side that grew."""
|
|
62
|
+
|
|
63
|
+
return Interval(
|
|
64
|
+
self.low if other.low >= self.low else -INF,
|
|
65
|
+
self.high if other.high <= self.high else INF,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> str:
|
|
69
|
+
return f"[{_format(self.low)}, {_format(self.high)}]"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
UNBOUNDED = Interval(-INF, INF)
|
|
73
|
+
NON_NEGATIVE = Interval(0, INF)
|
|
74
|
+
|
|
75
|
+
_NUMERIC_CALLS: Mapping[str, Interval] = {
|
|
76
|
+
"python.builtins.int": UNBOUNDED,
|
|
77
|
+
"python.builtins.float": UNBOUNDED,
|
|
78
|
+
"python.builtins.round": UNBOUNDED,
|
|
79
|
+
"python.builtins.len": NON_NEGATIVE,
|
|
80
|
+
"python.builtins.abs": NON_NEGATIVE,
|
|
81
|
+
"python.builtins.ord": Interval(0, 0x10FFFF),
|
|
82
|
+
}
|
|
83
|
+
_HULL_CALLS = frozenset({"python.builtins.min", "python.builtins.max"})
|
|
84
|
+
|
|
85
|
+
State = Mapping[Value, Interval]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _times(a: float, b: float) -> float:
|
|
89
|
+
return 0.0 if a == 0 or b == 0 else a * b
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RangeFacts:
|
|
93
|
+
def __init__(self, states: Mapping[BlockId, State]) -> None:
|
|
94
|
+
self._states: Mapping[BlockId, State] = MappingProxyType(
|
|
95
|
+
{block: MappingProxyType(dict(state)) for block, state in states.items()}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def at(self, block: BlockId) -> State:
|
|
99
|
+
"""The intervals known at the end of ``block``; empty when it is unreachable."""
|
|
100
|
+
|
|
101
|
+
return self._states.get(block, MappingProxyType({}))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _RangeProblem(DataflowProblem[State]):
|
|
105
|
+
direction: ClassVar[Direction] = Direction.FORWARD
|
|
106
|
+
|
|
107
|
+
def __init__(self, function: FunctionIR) -> None:
|
|
108
|
+
self.function = function
|
|
109
|
+
self.blocks = {block.id: block for block in function.blocks}
|
|
110
|
+
self.defs: dict[Value, Instruction] = {
|
|
111
|
+
i.result: i for block in function.blocks for i in block.instructions if i.result
|
|
112
|
+
}
|
|
113
|
+
self._widened: dict[Value, Interval] = {}
|
|
114
|
+
|
|
115
|
+
def initial(self) -> State:
|
|
116
|
+
return MappingProxyType({})
|
|
117
|
+
|
|
118
|
+
def join(self, a: State, b: State) -> State:
|
|
119
|
+
return MappingProxyType(
|
|
120
|
+
{value: a[value].join(b[value]) for value in a if value in b}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def evaluate(self, block: BasicBlock, incoming: Mapping[BlockId, State]) -> State:
|
|
124
|
+
states = list(incoming.values())
|
|
125
|
+
state: dict[Value, Interval] = dict(states[0]) if states else {}
|
|
126
|
+
for other in states[1:]:
|
|
127
|
+
state = dict(self.join(state, other))
|
|
128
|
+
for instruction in block.instructions:
|
|
129
|
+
if instruction.result is None:
|
|
130
|
+
continue
|
|
131
|
+
found = (
|
|
132
|
+
self.phi(instruction, incoming)
|
|
133
|
+
if isinstance(instruction, Phi)
|
|
134
|
+
else self.instruction(instruction, state)
|
|
135
|
+
)
|
|
136
|
+
if found is None:
|
|
137
|
+
state.pop(instruction.result, None)
|
|
138
|
+
else:
|
|
139
|
+
state[instruction.result] = found
|
|
140
|
+
return MappingProxyType(state)
|
|
141
|
+
|
|
142
|
+
def flow(self, cfg: CFG, block_id: BlockId, incoming: Mapping[BlockId, State]) -> Mapping[BlockId, State]:
|
|
143
|
+
block = self.blocks[block_id]
|
|
144
|
+
state = self.evaluate(block, incoming)
|
|
145
|
+
exits = {target: state for target in block.exception_targets}
|
|
146
|
+
terminator = block.terminator
|
|
147
|
+
if isinstance(terminator, Branch):
|
|
148
|
+
return {
|
|
149
|
+
**exits,
|
|
150
|
+
terminator.then_block: self.refined(state, terminator.condition, True),
|
|
151
|
+
terminator.else_block: self.refined(state, terminator.condition, False),
|
|
152
|
+
}
|
|
153
|
+
if isinstance(terminator, Jump):
|
|
154
|
+
return {**exits, terminator.target: state}
|
|
155
|
+
if isinstance(terminator, ForNext):
|
|
156
|
+
return {**exits, terminator.body: state, terminator.exit: state}
|
|
157
|
+
return exits
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------ transfer
|
|
160
|
+
|
|
161
|
+
def phi(self, phi: Phi, incoming: Mapping[BlockId, State]) -> Interval | None:
|
|
162
|
+
result: Interval | None = None
|
|
163
|
+
for value, predecessor in phi.incoming:
|
|
164
|
+
if predecessor not in incoming:
|
|
165
|
+
continue
|
|
166
|
+
found = incoming[predecessor].get(value)
|
|
167
|
+
if found is None:
|
|
168
|
+
return None
|
|
169
|
+
result = found if result is None else result.join(found)
|
|
170
|
+
if result is None:
|
|
171
|
+
return None
|
|
172
|
+
previous = self._widened.get(phi.result)
|
|
173
|
+
widened = result if previous is None else previous.widen(previous.join(result))
|
|
174
|
+
self._widened[phi.result] = widened
|
|
175
|
+
return widened
|
|
176
|
+
|
|
177
|
+
def instruction(self, instruction: Instruction, state: Mapping[Value, Interval]) -> Interval | None:
|
|
178
|
+
if isinstance(instruction, Constant):
|
|
179
|
+
value = instruction.value
|
|
180
|
+
if isinstance(value, bool | int | float) and value == value: # noqa: PLR0124 - NaN
|
|
181
|
+
return Interval(float(value), float(value))
|
|
182
|
+
return None
|
|
183
|
+
if isinstance(instruction, BinaryOp):
|
|
184
|
+
left, right = state.get(instruction.left), state.get(instruction.right)
|
|
185
|
+
if left is None or right is None:
|
|
186
|
+
return None
|
|
187
|
+
return self.binary(instruction.operator, left, right)
|
|
188
|
+
if isinstance(instruction, UnaryOp):
|
|
189
|
+
operand = state.get(instruction.operand)
|
|
190
|
+
if instruction.operator == "not":
|
|
191
|
+
return Interval(0, 1)
|
|
192
|
+
if operand is None:
|
|
193
|
+
return None
|
|
194
|
+
if instruction.operator == "neg":
|
|
195
|
+
return Interval(-operand.high, -operand.low)
|
|
196
|
+
return operand if instruction.operator == "pos" else UNBOUNDED
|
|
197
|
+
if isinstance(instruction, Compare):
|
|
198
|
+
return Interval(0, 1)
|
|
199
|
+
if isinstance(instruction, Call):
|
|
200
|
+
callee = self.defs.get(instruction.callee)
|
|
201
|
+
if not isinstance(callee, Symbol):
|
|
202
|
+
return None
|
|
203
|
+
name = callee.symbol_id.canonical_name
|
|
204
|
+
if name in _NUMERIC_CALLS:
|
|
205
|
+
return _NUMERIC_CALLS[name]
|
|
206
|
+
if name in _HULL_CALLS and instruction.arguments and not instruction.keywords:
|
|
207
|
+
hull: Interval | None = None
|
|
208
|
+
for argument in instruction.arguments:
|
|
209
|
+
found = state.get(argument)
|
|
210
|
+
if found is None:
|
|
211
|
+
return None
|
|
212
|
+
hull = found if hull is None else hull.join(found)
|
|
213
|
+
return hull
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def binary(operator: str, left: Interval, right: Interval) -> Interval | None:
|
|
218
|
+
if operator == "add":
|
|
219
|
+
return Interval(left.low + right.low, left.high + right.high)
|
|
220
|
+
if operator == "sub":
|
|
221
|
+
return Interval(left.low - right.high, left.high - right.low)
|
|
222
|
+
if operator == "mul":
|
|
223
|
+
products = [
|
|
224
|
+
_times(a, b) for a in (left.low, left.high) for b in (right.low, right.high)
|
|
225
|
+
]
|
|
226
|
+
return Interval(min(products), max(products))
|
|
227
|
+
if operator in ("div", "floor_div", "mod", "pow", "bit_or", "bit_xor", "bit_and", "lshift", "rshift"):
|
|
228
|
+
return UNBOUNDED
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------ refinement
|
|
232
|
+
|
|
233
|
+
def refined(self, state: State, condition: Value, truth: bool) -> State:
|
|
234
|
+
overrides: dict[Value, Interval] = {}
|
|
235
|
+
self.refine(dict(state), condition, truth, overrides)
|
|
236
|
+
return MappingProxyType({**state, **overrides}) if overrides else state
|
|
237
|
+
|
|
238
|
+
def refine(
|
|
239
|
+
self, state: dict[Value, Interval], condition: Value, truth: bool, overrides: dict[Value, Interval]
|
|
240
|
+
) -> None:
|
|
241
|
+
definition = self.defs.get(condition)
|
|
242
|
+
if isinstance(definition, UnaryOp) and definition.operator == "not":
|
|
243
|
+
self.refine(state, definition.operand, not truth, overrides)
|
|
244
|
+
elif isinstance(definition, BoolOp) and (definition.operator == "and") == truth:
|
|
245
|
+
for value in definition.values:
|
|
246
|
+
self.refine(state, value, truth, overrides)
|
|
247
|
+
elif isinstance(definition, Compare):
|
|
248
|
+
left, right = definition.left, definition.right
|
|
249
|
+
if left not in state or right not in state:
|
|
250
|
+
return
|
|
251
|
+
operator = definition.operator
|
|
252
|
+
if operator == "eq" and truth or operator == "not_eq" and not truth:
|
|
253
|
+
low = max(state[left].low, state[right].low)
|
|
254
|
+
high = min(state[left].high, state[right].high)
|
|
255
|
+
if low <= high:
|
|
256
|
+
overrides[left] = overrides[right] = Interval(low, high)
|
|
257
|
+
state[left] = state[right] = Interval(low, high)
|
|
258
|
+
return
|
|
259
|
+
if operator not in ("lt", "lt_eq", "gt", "gt_eq"):
|
|
260
|
+
return
|
|
261
|
+
ascending = (operator in ("lt", "lt_eq")) == truth
|
|
262
|
+
lower, upper = (left, right) if ascending else (right, left)
|
|
263
|
+
bounded_lower = Interval(state[lower].low, min(state[lower].high, state[upper].high))
|
|
264
|
+
bounded_upper = Interval(max(state[upper].low, state[lower].low), state[upper].high)
|
|
265
|
+
overrides[lower], overrides[upper] = bounded_lower, bounded_upper
|
|
266
|
+
state[lower], state[upper] = bounded_lower, bounded_upper
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def analyze_ranges(function: FunctionIR, cfg: CFG) -> RangeFacts:
|
|
270
|
+
problem = _RangeProblem(function)
|
|
271
|
+
solution = solve(problem, cfg)
|
|
272
|
+
states: dict[BlockId, State] = {}
|
|
273
|
+
for block in function.blocks:
|
|
274
|
+
if solution.reached(block.id):
|
|
275
|
+
states[block.id] = problem.evaluate(block, solution.incoming(block.id))
|
|
276
|
+
return RangeFacts(states)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class RangeAnalysis(FunctionAnalysis[RangeFacts]):
|
|
280
|
+
name: ClassVar[str] = "abstract.ranges"
|
|
281
|
+
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, CFGAnalysis})
|
|
282
|
+
|
|
283
|
+
@classmethod
|
|
284
|
+
def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> RangeFacts:
|
|
285
|
+
return analyze_ranges(ctx.get(SSAAnalysis, function), ctx.get(CFGAnalysis, function))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Abstract values (architecture §18).
|
|
2
|
+
|
|
3
|
+
An ``AbstractValue`` records what is known about one SSA value: its constant on a flat
|
|
4
|
+
lattice, the set of Python types it may have (``None`` when unknown) and the truthiness
|
|
5
|
+
that follows. Taints, ranges, string constraints and nullability join this record as
|
|
6
|
+
their analyses arrive.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
from coretrace_python.dataflow import BOTTOM, TOP, Element, FlatLattice
|
|
15
|
+
|
|
16
|
+
_CONSTANTS: FlatLattice[object] = FlatLattice()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Truth(Enum):
|
|
20
|
+
TRUE = "true"
|
|
21
|
+
FALSE = "false"
|
|
22
|
+
UNKNOWN = "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AbstractValue:
|
|
27
|
+
constant: Element[object]
|
|
28
|
+
types: frozenset[str] | None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def of(cls, value: object) -> AbstractValue:
|
|
32
|
+
return cls(value, frozenset({type(value).__name__}))
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def unknown(cls, types: frozenset[str] | None = None) -> AbstractValue:
|
|
36
|
+
return cls(TOP, types)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def bottom(cls) -> AbstractValue:
|
|
40
|
+
return cls(BOTTOM, frozenset())
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def truthiness(self) -> Truth:
|
|
44
|
+
if self.constant is TOP or self.constant is BOTTOM:
|
|
45
|
+
if self.types == frozenset({"NoneType"}):
|
|
46
|
+
return Truth.FALSE
|
|
47
|
+
return Truth.UNKNOWN
|
|
48
|
+
return Truth.TRUE if self.constant else Truth.FALSE
|
|
49
|
+
|
|
50
|
+
def join(self, other: AbstractValue) -> AbstractValue:
|
|
51
|
+
if self.constant is BOTTOM:
|
|
52
|
+
return other
|
|
53
|
+
if other.constant is BOTTOM:
|
|
54
|
+
return self
|
|
55
|
+
types = None if self.types is None or other.types is None else self.types | other.types
|
|
56
|
+
return AbstractValue(_CONSTANTS.join(self.constant, other.constant), types)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Analysis infrastructure: typed providers managed as a lazy, cached dependency DAG."""
|
|
2
|
+
|
|
3
|
+
from coretrace_python.analysis.manager import (
|
|
4
|
+
AnalysisError,
|
|
5
|
+
AnalysisManager,
|
|
6
|
+
CyclicDependencyError,
|
|
7
|
+
MissingInputError,
|
|
8
|
+
UndeclaredDependencyError,
|
|
9
|
+
UnregisteredAnalysisError,
|
|
10
|
+
)
|
|
11
|
+
from coretrace_python.analysis.provider import (
|
|
12
|
+
Analysis,
|
|
13
|
+
AnalysisContext,
|
|
14
|
+
AnyAnalysis,
|
|
15
|
+
FunctionAnalysis,
|
|
16
|
+
TransformationPass,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Analysis",
|
|
21
|
+
"AnalysisContext",
|
|
22
|
+
"AnalysisError",
|
|
23
|
+
"AnalysisManager",
|
|
24
|
+
"AnyAnalysis",
|
|
25
|
+
"CyclicDependencyError",
|
|
26
|
+
"FunctionAnalysis",
|
|
27
|
+
"MissingInputError",
|
|
28
|
+
"TransformationPass",
|
|
29
|
+
"UndeclaredDependencyError",
|
|
30
|
+
"UnregisteredAnalysisError",
|
|
31
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The Analysis Manager: registry, dependency DAG, lazy evaluation, cache, invalidation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, overload
|
|
6
|
+
|
|
7
|
+
from coretrace_python.analysis.provider import (
|
|
8
|
+
Analysis,
|
|
9
|
+
AnyAnalysis,
|
|
10
|
+
FunctionAnalysis,
|
|
11
|
+
R,
|
|
12
|
+
TransformationPass,
|
|
13
|
+
)
|
|
14
|
+
from coretrace_python.hir import nodes
|
|
15
|
+
from coretrace_python.source import SourceSpan
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnalysisError(Exception):
|
|
19
|
+
"""Misuse of the analysis registry or dependency declarations."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnregisteredAnalysisError(AnalysisError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UndeclaredDependencyError(AnalysisError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CyclicDependencyError(AnalysisError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MissingInputError(AnalysisError):
|
|
35
|
+
"""An analysis that must be provided by the engine was requested before being provided."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_CacheKey = tuple[AnyAnalysis, SourceSpan | None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AnalysisManager:
|
|
42
|
+
"""Compute registered analyses lazily, once, and share their results."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, module: nodes.Module) -> None:
|
|
45
|
+
self._module = module
|
|
46
|
+
self._registry: set[AnyAnalysis] = set()
|
|
47
|
+
self._cache: dict[_CacheKey, Any] = {}
|
|
48
|
+
self._computing: list[AnyAnalysis] = []
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def module(self) -> nodes.Module:
|
|
52
|
+
return self._module
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------ registry
|
|
55
|
+
|
|
56
|
+
def register(self, *analyses: AnyAnalysis) -> None:
|
|
57
|
+
for analysis in analyses:
|
|
58
|
+
self._registry.add(analysis)
|
|
59
|
+
self._check_acyclic(analysis)
|
|
60
|
+
|
|
61
|
+
def analysis(self, name: str) -> AnyAnalysis:
|
|
62
|
+
"""Look a registered analysis up by its declared name."""
|
|
63
|
+
|
|
64
|
+
for analysis in self._registry:
|
|
65
|
+
if analysis.name == name:
|
|
66
|
+
return analysis
|
|
67
|
+
raise KeyError(name)
|
|
68
|
+
|
|
69
|
+
def dependencies(self, analysis: AnyAnalysis) -> frozenset[AnyAnalysis]:
|
|
70
|
+
"""Transitive closure of ``analysis.requires``."""
|
|
71
|
+
|
|
72
|
+
found: set[AnyAnalysis] = set()
|
|
73
|
+
pending = list(analysis.requires)
|
|
74
|
+
while pending:
|
|
75
|
+
dependency = pending.pop()
|
|
76
|
+
if dependency not in found:
|
|
77
|
+
found.add(dependency)
|
|
78
|
+
pending.extend(dependency.requires)
|
|
79
|
+
return frozenset(found)
|
|
80
|
+
|
|
81
|
+
def _check_acyclic(self, root: AnyAnalysis) -> None:
|
|
82
|
+
path: list[AnyAnalysis] = []
|
|
83
|
+
|
|
84
|
+
def visit(analysis: AnyAnalysis) -> None:
|
|
85
|
+
if analysis in path:
|
|
86
|
+
cycle = [*path[path.index(analysis) :], analysis]
|
|
87
|
+
raise CyclicDependencyError(
|
|
88
|
+
"dependency cycle: " + " -> ".join(a.name for a in cycle)
|
|
89
|
+
)
|
|
90
|
+
path.append(analysis)
|
|
91
|
+
for dependency in analysis.requires:
|
|
92
|
+
visit(dependency)
|
|
93
|
+
path.pop()
|
|
94
|
+
|
|
95
|
+
visit(root)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------ evaluation
|
|
98
|
+
|
|
99
|
+
@overload
|
|
100
|
+
def get(self, analysis: type[Analysis[R]], function: None = None) -> R: ...
|
|
101
|
+
|
|
102
|
+
@overload
|
|
103
|
+
def get(self, analysis: type[FunctionAnalysis[R]], function: nodes.Function) -> R: ...
|
|
104
|
+
|
|
105
|
+
def get(self, analysis: AnyAnalysis, function: nodes.Function | None = None) -> Any:
|
|
106
|
+
self._check_target(analysis, function)
|
|
107
|
+
if analysis not in self._registry:
|
|
108
|
+
raise UnregisteredAnalysisError(f"analysis {analysis.name!r} is not registered")
|
|
109
|
+
if self._computing and analysis not in self._computing[-1].requires:
|
|
110
|
+
current = self._computing[-1]
|
|
111
|
+
raise UndeclaredDependencyError(
|
|
112
|
+
f"{current.name} requests {analysis.name} without declaring it in requires"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
key = self._key(analysis, function)
|
|
116
|
+
if key in self._cache:
|
|
117
|
+
return self._cache[key]
|
|
118
|
+
|
|
119
|
+
self._computing.append(analysis)
|
|
120
|
+
try:
|
|
121
|
+
if function is None:
|
|
122
|
+
assert issubclass(analysis, Analysis)
|
|
123
|
+
result = analysis.compute(self)
|
|
124
|
+
else:
|
|
125
|
+
assert issubclass(analysis, FunctionAnalysis)
|
|
126
|
+
result = analysis.compute(self, function)
|
|
127
|
+
finally:
|
|
128
|
+
self._computing.pop()
|
|
129
|
+
self._cache[key] = result
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def provide(self, analysis: type[Analysis[R]], result: R) -> None:
|
|
133
|
+
"""Supply the result of a module-level analysis the engine computes elsewhere."""
|
|
134
|
+
|
|
135
|
+
self._check_target(analysis, None)
|
|
136
|
+
if analysis not in self._registry:
|
|
137
|
+
raise UnregisteredAnalysisError(f"analysis {analysis.name!r} is not registered")
|
|
138
|
+
self._cache[self._key(analysis, None)] = result
|
|
139
|
+
|
|
140
|
+
def is_cached(self, analysis: AnyAnalysis, function: nodes.Function | None = None) -> bool:
|
|
141
|
+
self._check_target(analysis, function)
|
|
142
|
+
return self._key(analysis, function) in self._cache
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------ invalidation
|
|
145
|
+
|
|
146
|
+
def run(self, transformation: type[TransformationPass]) -> None:
|
|
147
|
+
"""Run a transformation, then drop every cached result it does not preserve."""
|
|
148
|
+
|
|
149
|
+
transformation.run(self)
|
|
150
|
+
self._cache = {
|
|
151
|
+
key: result for key, result in self._cache.items() if key[0] in transformation.preserves
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------ helpers
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _check_target(analysis: AnyAnalysis, function: nodes.Function | None) -> None:
|
|
158
|
+
if issubclass(analysis, FunctionAnalysis) and function is None:
|
|
159
|
+
raise TypeError(f"{analysis.name} is a function analysis and needs a function")
|
|
160
|
+
if issubclass(analysis, Analysis) and function is not None:
|
|
161
|
+
raise TypeError(f"{analysis.name} is a module analysis and takes no function")
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _key(analysis: AnyAnalysis, function: nodes.Function | None) -> _CacheKey:
|
|
165
|
+
return (analysis, None if function is None else function.span)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Typed analysis providers and the context they compute in (architecture §8, §12).
|
|
2
|
+
|
|
3
|
+
An analysis is a class, not an instance: its ``name`` and ``version`` identify cached
|
|
4
|
+
results, its ``requires`` declares the dependency DAG, and ``compute`` builds the
|
|
5
|
+
result from an ``AnalysisContext``. Read-only analyses never modify shared IR; a
|
|
6
|
+
``TransformationPass`` may, and must say what it preserves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from typing import Any, ClassVar, Generic, Protocol, TypeVar, overload
|
|
13
|
+
|
|
14
|
+
from coretrace_python.hir import nodes
|
|
15
|
+
|
|
16
|
+
R = TypeVar("R")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Analysis(ABC, Generic[R]):
|
|
20
|
+
"""A module-level analysis producing one immutable result per module."""
|
|
21
|
+
|
|
22
|
+
name: ClassVar[str]
|
|
23
|
+
version: ClassVar[int] = 1
|
|
24
|
+
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset()
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def compute(cls, ctx: AnalysisContext) -> R:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FunctionAnalysis(ABC, Generic[R]):
|
|
33
|
+
"""A function-level analysis producing one result per function, on demand."""
|
|
34
|
+
|
|
35
|
+
name: ClassVar[str]
|
|
36
|
+
version: ClassVar[int] = 1
|
|
37
|
+
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset()
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def compute(cls, ctx: AnalysisContext, function: nodes.Function) -> R:
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
AnyAnalysis = type[Analysis[Any]] | type[FunctionAnalysis[Any]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TransformationPass(ABC):
|
|
49
|
+
"""A pass that may change shared state and therefore invalidates cached results.
|
|
50
|
+
|
|
51
|
+
Every cached analysis not listed in ``preserves`` is dropped after the pass runs.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name: ClassVar[str]
|
|
55
|
+
preserves: ClassVar[frozenset[AnyAnalysis]] = frozenset()
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def run(cls, ctx: AnalysisContext) -> None:
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AnalysisContext(Protocol):
|
|
64
|
+
"""What an analysis sees while computing: the module and its declared dependencies."""
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def module(self) -> nodes.Module: ...
|
|
68
|
+
|
|
69
|
+
@overload
|
|
70
|
+
def get(self, analysis: type[Analysis[R]], function: None = None) -> R: ...
|
|
71
|
+
|
|
72
|
+
@overload
|
|
73
|
+
def get(self, analysis: type[FunctionAnalysis[R]], function: nodes.Function) -> R: ...
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Requirements the project's policy denies or leaves unpinned."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from coretrace_python.analysis import AnyAnalysis
|
|
9
|
+
from coretrace_python.dependency import DependencyAnalysis
|
|
10
|
+
from coretrace_python.findings import Confidence, Finding, Severity
|
|
11
|
+
from coretrace_python.plugins import ProjectContext, ProjectPlugin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DependencyPolicyPlugin(ProjectPlugin):
|
|
15
|
+
name: ClassVar[str] = "dependency-policy"
|
|
16
|
+
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({DependencyAnalysis})
|
|
17
|
+
|
|
18
|
+
def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
|
|
19
|
+
findings: list[Finding] = []
|
|
20
|
+
for requirement in ctx.dependencies.requirements:
|
|
21
|
+
metadata = {"package": requirement.name, "specifier": requirement.specifier}
|
|
22
|
+
if ctx.policy.denies(requirement.name):
|
|
23
|
+
findings.append(
|
|
24
|
+
Finding(
|
|
25
|
+
"denied-dependency",
|
|
26
|
+
f"{requirement.name} is denied by the dependency policy",
|
|
27
|
+
Severity.HIGH,
|
|
28
|
+
Confidence.HIGH,
|
|
29
|
+
requirement.span,
|
|
30
|
+
None,
|
|
31
|
+
metadata,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
elif ctx.policy.require_pinned and requirement.pinned is None:
|
|
35
|
+
findings.append(
|
|
36
|
+
Finding(
|
|
37
|
+
"unpinned-dependency",
|
|
38
|
+
f"{requirement.name}{requirement.specifier} is not pinned to one version",
|
|
39
|
+
Severity.LOW,
|
|
40
|
+
Confidence.HIGH,
|
|
41
|
+
requirement.span,
|
|
42
|
+
None,
|
|
43
|
+
metadata,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return findings
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
name = "reachable-vulnerability"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
plugin_api = ">=1,<2"
|
|
4
|
+
requires = ["dependency.graph", "interprocedural.callgraph"]
|
|
5
|
+
provides = ["vulnerability.reachable-vulnerability"]
|
|
6
|
+
|
|
7
|
+
[entrypoint]
|
|
8
|
+
module = "reachable_vulnerability"
|
|
9
|
+
class = "ReachableVulnerabilityPlugin"
|