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.
Files changed (126) hide show
  1. coretrace_python/__init__.py +4 -0
  2. coretrace_python/__main__.py +4 -0
  3. coretrace_python/abstract/__init__.py +45 -0
  4. coretrace_python/abstract/constants.py +226 -0
  5. coretrace_python/abstract/heap.py +252 -0
  6. coretrace_python/abstract/ranges.py +285 -0
  7. coretrace_python/abstract/values.py +56 -0
  8. coretrace_python/analysis/__init__.py +31 -0
  9. coretrace_python/analysis/manager.py +165 -0
  10. coretrace_python/analysis/provider.py +73 -0
  11. coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py +46 -0
  12. coretrace_python/bundled/dependency/dependency_policy/plugin.toml +9 -0
  13. coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml +9 -0
  14. coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +56 -0
  15. coretrace_python/bundled/dependency/sample_advisories/plugin.toml +9 -0
  16. coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +109 -0
  17. coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml +9 -0
  18. coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +43 -0
  19. coretrace_python/bundled/models/cli/cli_models.py +35 -0
  20. coretrace_python/bundled/models/cli/plugin.toml +9 -0
  21. coretrace_python/bundled/models/credentials/credential_models.py +43 -0
  22. coretrace_python/bundled/models/credentials/plugin.toml +9 -0
  23. coretrace_python/bundled/models/django/django_models.py +123 -0
  24. coretrace_python/bundled/models/django/plugin.toml +9 -0
  25. coretrace_python/bundled/models/fastapi/fastapi_models.py +29 -0
  26. coretrace_python/bundled/models/fastapi/plugin.toml +9 -0
  27. coretrace_python/bundled/models/flask/flask_models.py +53 -0
  28. coretrace_python/bundled/models/flask/plugin.toml +9 -0
  29. coretrace_python/bundled/models/http_clients/http_client_models.py +43 -0
  30. coretrace_python/bundled/models/http_clients/plugin.toml +9 -0
  31. coretrace_python/bundled/models/python_stdlib/plugin.toml +9 -0
  32. coretrace_python/bundled/models/python_stdlib/python_stdlib.py +68 -0
  33. coretrace_python/bundled/models/sqlalchemy/plugin.toml +9 -0
  34. coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py +47 -0
  35. coretrace_python/bundled/secrets/config_secrets/config_secrets.py +38 -0
  36. coretrace_python/bundled/secrets/config_secrets/plugin.toml +9 -0
  37. coretrace_python/bundled/secrets/hardcoded_secrets/hardcoded_secrets.py +19 -0
  38. coretrace_python/bundled/secrets/hardcoded_secrets/plugin.toml +9 -0
  39. coretrace_python/bundled/security/command_injection/command_injection.py +17 -0
  40. coretrace_python/bundled/security/command_injection/plugin.toml +9 -0
  41. coretrace_python/bundled/security/insecure_deserialization/insecure_deserialization.py +17 -0
  42. coretrace_python/bundled/security/insecure_deserialization/plugin.toml +9 -0
  43. coretrace_python/bundled/security/open_redirect/open_redirect.py +17 -0
  44. coretrace_python/bundled/security/open_redirect/plugin.toml +9 -0
  45. coretrace_python/bundled/security/path_traversal/path_traversal.py +17 -0
  46. coretrace_python/bundled/security/path_traversal/plugin.toml +9 -0
  47. coretrace_python/bundled/security/plaintext_credentials/plaintext_credentials.py +21 -0
  48. coretrace_python/bundled/security/plaintext_credentials/plugin.toml +9 -0
  49. coretrace_python/bundled/security/sql_injection/plugin.toml +9 -0
  50. coretrace_python/bundled/security/sql_injection/sql_injection.py +17 -0
  51. coretrace_python/bundled/security/ssrf/plugin.toml +9 -0
  52. coretrace_python/bundled/security/ssrf/ssrf.py +17 -0
  53. coretrace_python/bundled/security/xss/plugin.toml +9 -0
  54. coretrace_python/bundled/security/xss/xss.py +17 -0
  55. coretrace_python/bundled/syntax/dangerous_eval/dangerous_eval.py +19 -0
  56. coretrace_python/bundled/syntax/dangerous_eval/plugin.toml +9 -0
  57. coretrace_python/bundled/syntax/flask_debug/flask_debug.py +63 -0
  58. coretrace_python/bundled/syntax/flask_debug/plugin.toml +9 -0
  59. coretrace_python/bundled/syntax/missing_timeout/missing_timeout.py +51 -0
  60. coretrace_python/bundled/syntax/missing_timeout/plugin.toml +9 -0
  61. coretrace_python/bundled/syntax/weak_crypto/plugin.toml +9 -0
  62. coretrace_python/bundled/syntax/weak_crypto/weak_crypto.py +19 -0
  63. coretrace_python/cache.py +310 -0
  64. coretrace_python/cfg/__init__.py +46 -0
  65. coretrace_python/cfg/builder.py +589 -0
  66. coretrace_python/cfg/dominance.py +183 -0
  67. coretrace_python/cfg/model.py +166 -0
  68. coretrace_python/cli.py +216 -0
  69. coretrace_python/dataflow/__init__.py +29 -0
  70. coretrace_python/dataflow/lattice.py +78 -0
  71. coretrace_python/dataflow/solver.py +96 -0
  72. coretrace_python/dependency/__init__.py +44 -0
  73. coretrace_python/dependency/advisories.py +168 -0
  74. coretrace_python/dependency/correlation.py +87 -0
  75. coretrace_python/dependency/graph.py +274 -0
  76. coretrace_python/dependency/policy.py +65 -0
  77. coretrace_python/dependency/sbom.py +65 -0
  78. coretrace_python/engine.py +688 -0
  79. coretrace_python/findings/__init__.py +13 -0
  80. coretrace_python/findings/coverage.py +45 -0
  81. coretrace_python/findings/model.py +49 -0
  82. coretrace_python/findings/refutation.py +427 -0
  83. coretrace_python/frontend/__init__.py +18 -0
  84. coretrace_python/frontend/ast_adapter.py +447 -0
  85. coretrace_python/frontend/parser.py +23 -0
  86. coretrace_python/hir/__init__.py +5 -0
  87. coretrace_python/hir/nodes.py +584 -0
  88. coretrace_python/hir/visitors.py +36 -0
  89. coretrace_python/interprocedural/__init__.py +49 -0
  90. coretrace_python/interprocedural/callgraph.py +291 -0
  91. coretrace_python/interprocedural/modulegraph.py +218 -0
  92. coretrace_python/interprocedural/summaries.py +463 -0
  93. coretrace_python/ir/__init__.py +5 -0
  94. coretrace_python/ir/defuse.py +74 -0
  95. coretrace_python/ir/lowering.py +575 -0
  96. coretrace_python/ir/model.py +481 -0
  97. coretrace_python/ir/printer.py +201 -0
  98. coretrace_python/ir/ssa.py +277 -0
  99. coretrace_python/plugins/__init__.py +58 -0
  100. coretrace_python/plugins/api.py +153 -0
  101. coretrace_python/plugins/detectors.py +114 -0
  102. coretrace_python/plugins/loader.py +84 -0
  103. coretrace_python/plugins/manifest.py +112 -0
  104. coretrace_python/plugins/registry.py +34 -0
  105. coretrace_python/plugins/secrets.py +330 -0
  106. coretrace_python/reporters/__init__.py +22 -0
  107. coretrace_python/reporters/json_format.py +48 -0
  108. coretrace_python/reporters/report.py +23 -0
  109. coretrace_python/reporters/sarif.py +70 -0
  110. coretrace_python/reporters/text.py +24 -0
  111. coretrace_python/semantic/__init__.py +9 -0
  112. coretrace_python/semantic/identity.py +39 -0
  113. coretrace_python/semantic/imports.py +131 -0
  114. coretrace_python/semantic/scopes.py +473 -0
  115. coretrace_python/semantic/symbols.py +87 -0
  116. coretrace_python/source/__init__.py +13 -0
  117. coretrace_python/source/manager.py +81 -0
  118. coretrace_python/source/model.py +57 -0
  119. coretrace_python/taint/__init__.py +55 -0
  120. coretrace_python/taint/engine.py +800 -0
  121. coretrace_python/taint/models.py +317 -0
  122. coretrace_python/taint/routes.py +99 -0
  123. coretrace_python_analyzer-0.1.0.dist-info/METADATA +74 -0
  124. coretrace_python_analyzer-0.1.0.dist-info/RECORD +126 -0
  125. coretrace_python_analyzer-0.1.0.dist-info/WHEEL +4 -0
  126. 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 = "dependency-policy"
2
+ version = "1.0.0"
3
+ plugin_api = ">=1,<2"
4
+ requires = ["dependency.graph"]
5
+ provides = ["policy.dependencies"]
6
+
7
+ [entrypoint]
8
+ module = "dependency_policy"
9
+ class = "DependencyPolicyPlugin"
@@ -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"