coretrace-python-analyzer 0.7.0__py3-none-any.whl → 0.9.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.
@@ -1,4 +1,4 @@
1
1
  """CoreTrace's Python static analysis frontend."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.9.0"
4
4
 
coretrace_python/cache.py CHANGED
@@ -25,6 +25,7 @@ from coretrace_python.findings import Confidence, Finding, Severity
25
25
  from coretrace_python.interprocedural import (
26
26
  Arguments,
27
27
  CallSite,
28
+ Cleared,
28
29
  ExternalCall,
29
30
  ExternalSymbol,
30
31
  FunctionSummary,
@@ -41,7 +42,7 @@ from coretrace_python.interprocedural import (
41
42
  from coretrace_python.semantic.symbols import SymbolId
42
43
  from coretrace_python.source import SourceId, SourceSpan
43
44
 
44
- CACHE_FORMAT = 9
45
+ CACHE_FORMAT = 10
45
46
 
46
47
 
47
48
  @dataclass(frozen=True)
@@ -237,6 +238,8 @@ def _encode_call(call: ExternalCall) -> dict[str, Any]:
237
238
  "location": _encode_span(call.location),
238
239
  "call_site": None if call.call_site is None else _encode_span(call.call_site),
239
240
  "given": _encode_arguments(call.arguments),
241
+ "argument_cleared": [_encode_cleared(c) for c in call.argument_cleared],
242
+ "keyword_cleared": [_encode_cleared(c) for c in call.keyword_cleared],
240
243
  }
241
244
 
242
245
 
@@ -249,9 +252,19 @@ def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
249
252
  _decode_span(data["location"]),
250
253
  None if site is None else _decode_span(site),
251
254
  _decode_arguments(data["given"]),
255
+ tuple(_decode_cleared(c) for c in data["argument_cleared"]),
256
+ tuple(_decode_cleared(c) for c in data["keyword_cleared"]),
252
257
  )
253
258
 
254
259
 
260
+ def _encode_cleared(cleared: Cleared) -> list[list[int]]:
261
+ return [[parameter, bits] for parameter, bits in cleared]
262
+
263
+
264
+ def _decode_cleared(data: Any) -> Cleared:
265
+ return tuple((_integer(parameter), _integer(bits)) for parameter, bits in data)
266
+
267
+
255
268
  def _encode_summary(summary: FunctionSummary) -> dict[str, Any]:
256
269
  return {
257
270
  "name": summary.name,
@@ -275,6 +288,7 @@ def _encode_summary(summary: FunctionSummary) -> dict[str, Any]:
275
288
  for w in summary.nonlocal_writes
276
289
  ],
277
290
  "static": summary.static,
291
+ "return_cleared": _encode_cleared(summary.return_cleared),
278
292
  }
279
293
 
280
294
 
@@ -303,6 +317,7 @@ def _decode_summary(data: Mapping[str, Any]) -> FunctionSummary:
303
317
  for w in data["nonlocal_writes"]
304
318
  ),
305
319
  bool(data["static"]),
320
+ _decode_cleared(data["return_cleared"]),
306
321
  )
307
322
 
308
323
 
coretrace_python/cli.py CHANGED
@@ -27,6 +27,7 @@ from coretrace_python.reporters import FORMATS, render
27
27
  from coretrace_python.semantic.imports import ImportResolutionError
28
28
  from coretrace_python.semantic.scopes import ScopeError
29
29
  from coretrace_python.source import SourceManager
30
+ from coretrace_python.taint import ModelError
30
31
 
31
32
  EXIT_CLEAN = 0
32
33
  EXIT_FINDINGS = 1
@@ -45,6 +46,7 @@ _ANALYSIS_ERRORS = (
45
46
  AnalysisError,
46
47
  ManifestError,
47
48
  IncompatiblePluginError,
49
+ ModelError,
48
50
  )
49
51
 
50
52
 
@@ -12,6 +12,7 @@ from coretrace_python.dependency.graph import (
12
12
  DEPENDENCY_FILES,
13
13
  Advisory,
14
14
  AdvisoryEntryPoint,
15
+ AttackerArgument,
15
16
  Condition,
16
17
  DependencyAnalysis,
17
18
  DependencyGraph,
@@ -31,6 +32,7 @@ __all__ = [
31
32
  "Advisory",
32
33
  "AdvisoryEntryPoint",
33
34
  "AdvisoryFileError",
35
+ "AttackerArgument",
34
36
  "Condition",
35
37
  "DependencyAnalysis",
36
38
  "DependencyGraph",
@@ -17,7 +17,13 @@ from collections.abc import Iterable, Iterator, Mapping
17
17
  from pathlib import Path
18
18
  from typing import Any
19
19
 
20
- from coretrace_python.dependency.graph import Advisory, AdvisoryEntryPoint, Condition, normalize
20
+ from coretrace_python.dependency.graph import (
21
+ Advisory,
22
+ AdvisoryEntryPoint,
23
+ AttackerArgument,
24
+ Condition,
25
+ normalize,
26
+ )
21
27
  from coretrace_python.findings import Severity
22
28
  from coretrace_python.semantic.symbols import SymbolId
23
29
 
@@ -155,6 +161,19 @@ def _entry_point_entry(entry_point: AdvisoryEntryPoint) -> dict[str, Any]:
155
161
  }
156
162
  if entry_point.read:
157
163
  entry["read"] = True
164
+ if entry_point.attacker_arguments:
165
+ entry["attacker_arguments"] = [_attacker_argument_entry(a) for a in entry_point.attacker_arguments]
166
+ return entry
167
+
168
+
169
+ def _attacker_argument_entry(argument: AttackerArgument) -> dict[str, Any]:
170
+ entry: dict[str, Any] = {}
171
+ if argument.argument is not None:
172
+ entry["argument"] = argument.argument
173
+ if argument.position is not None:
174
+ entry["position"] = argument.position
175
+ if argument.variadic:
176
+ entry["variadic"] = True
158
177
  return entry
159
178
 
160
179
 
@@ -206,9 +225,25 @@ def _entry_point(entry: Mapping[str, Any]) -> AdvisoryEntryPoint:
206
225
  str(entry["justification"]),
207
226
  tuple(_condition(c) for c in entry.get("conditions") or []),
208
227
  read,
228
+ tuple(_attacker_argument(a) for a in entry.get("attacker_arguments") or []),
209
229
  )
210
230
 
211
231
 
232
+ def _attacker_argument(entry: Mapping[str, Any]) -> AttackerArgument:
233
+ argument, position, variadic = entry.get("argument"), entry.get("position"), entry.get("variadic", False)
234
+ if argument is not None and not isinstance(argument, str):
235
+ raise TypeError(f"attacker argument name must be a string, got {argument!r}")
236
+ if position is not None and (not isinstance(position, int) or isinstance(position, bool) or position < 0):
237
+ raise TypeError(f"attacker argument position must be a non-negative integer, got {position!r}")
238
+ if not isinstance(variadic, bool):
239
+ raise TypeError(f"attacker argument variadic must be true or false, got {variadic!r}")
240
+ if argument is None and position is None:
241
+ raise ValueError("an attacker argument needs a name, a position or both")
242
+ if variadic and position is None:
243
+ raise ValueError("a variadic attacker argument needs the position it starts at")
244
+ return AttackerArgument(argument, position, variadic)
245
+
246
+
212
247
  def _condition(entry: Mapping[str, Any]) -> Condition:
213
248
  position = entry.get("position")
214
249
  if position is not None and (not isinstance(position, int) or isinstance(position, bool) or position < 0):
@@ -133,7 +133,10 @@ def correlate(
133
133
  continue
134
134
  hotspot = verdict is not None and verdict.status is Status.HOTSPOT
135
135
  for advisory in affected.get(flow.sink.symbol, ()):
136
- check = check_conditions(advisory.entry_point(flow.sink.symbol), flow.sink_arguments)
136
+ entry = advisory.entry_point(flow.sink.symbol)
137
+ if entry is not None and not any(entry.exploitable_through(p, k) for p, k in flow.passed_as):
138
+ continue
139
+ check = check_conditions(entry, flow.sink_arguments)
137
140
  if check.contradicted is None:
138
141
  findings.append(_exploitable(function, flow, advisory, verdict, hotspot, check))
139
142
  return tuple(findings)
@@ -166,6 +166,31 @@ class Condition:
166
166
  return self.kind == "argument"
167
167
 
168
168
 
169
+ @dataclass(frozen=True)
170
+ class AttackerArgument:
171
+ """An argument of an entry point that must carry the attacker's input: passed by
172
+ keyword ``argument``, or at ``position`` (from 0, a method's receiver excluded) when it
173
+ may be passed positionally; a ``variadic`` one, such as ``*pathnames``, also takes every
174
+ later position."""
175
+
176
+ argument: str | None = None
177
+ position: int | None = None
178
+ variadic: bool = False
179
+
180
+ def receives(self, position: int | None, keyword: str | None) -> bool:
181
+ """Whether the argument passed at ``position`` or as ``keyword`` is this one. An
182
+ argument unpacked with ``*`` or ``**`` has neither and may fill this one: like an
183
+ argument condition it leaves undecided, it does not rule the call out, so it is."""
184
+
185
+ if keyword is not None:
186
+ return keyword == self.argument
187
+ if position is None:
188
+ return True
189
+ if self.position is None:
190
+ return False
191
+ return position == self.position or (self.variadic and position > self.position)
192
+
193
+
169
194
  @dataclass(frozen=True)
170
195
  class AdvisoryEntryPoint:
171
196
  """A public API through which a project reaches an affected symbol, justified by the
@@ -173,12 +198,20 @@ class AdvisoryEntryPoint:
173
198
 
174
199
  An entry point is reached by a call, or — when ``read`` is set, for an attribute whose
175
200
  getter runs the affected code, such as a lazily parsed request body — by any read of
176
- it or of an attribute of it."""
201
+ it or of an attribute of it. When it names ``attacker_arguments``, attacker input
202
+ exploits it only through one of them; otherwise through any argument."""
177
203
 
178
204
  symbol: SymbolId
179
205
  justification: str
180
206
  conditions: tuple[Condition, ...] = ()
181
207
  read: bool = False
208
+ attacker_arguments: tuple[AttackerArgument, ...] = ()
209
+
210
+ def exploitable_through(self, position: int | None, keyword: str | None) -> bool:
211
+ """Whether attacker input in the argument at ``position`` or ``keyword`` can
212
+ exploit the vulnerability."""
213
+
214
+ return not self.attacker_arguments or any(a.receives(position, keyword) for a in self.attacker_arguments)
182
215
 
183
216
 
184
217
  DIRECT = "affected symbol, changed by the fix"
@@ -70,6 +70,7 @@ from coretrace_python.interprocedural import (
70
70
  CallGraph,
71
71
  CallGraphAnalysis,
72
72
  CallSite,
73
+ ClearingAnalysis,
73
74
  FunctionSummary,
74
75
  ModuleFunction,
75
76
  ModuleGraph,
@@ -149,6 +150,7 @@ ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
149
150
  DependencyAnalysis,
150
151
  RegisteredRoutes,
151
152
  EscapedTemplates,
153
+ ClearingAnalysis,
152
154
  )
153
155
 
154
156
 
@@ -188,6 +190,7 @@ class ResultsEvicted(TransformationPass):
188
190
  DependencyAnalysis,
189
191
  RegisteredRoutes,
190
192
  EscapedTemplates,
193
+ ClearingAnalysis,
191
194
  }
192
195
  )
193
196
 
@@ -246,7 +249,9 @@ def build_manager(
246
249
  """A manager with every engine analysis registered and the engine inputs provided."""
247
250
 
248
251
  manager = _register_all(module)
249
- manager.provide(SecurityModelAnalysis, (models or SecurityModelRegistry()).freeze())
252
+ table = (models or SecurityModelRegistry()).freeze()
253
+ manager.provide(SecurityModelAnalysis, table)
254
+ manager.provide(ClearingAnalysis, table.clearing())
250
255
  manager.provide(ProjectSummaries, SummaryIndex())
251
256
  manager.provide(RegisteredRoutes, _routes_of(manager))
252
257
  return manager
@@ -276,10 +281,14 @@ def load_plugins(plugin_roots: Sequence[Path], manager: AnalysisManager) -> Plug
276
281
  return registry
277
282
 
278
283
 
279
- def plugin_models(plugins: Iterable[Plugin]) -> ModelTable:
284
+ def plugin_models(plugins: Iterable[Plugin], root: Path | None = None) -> ModelTable:
285
+ """The models of ``plugins``, with those they read from the project at ``root``."""
286
+
280
287
  models = SecurityModelRegistry()
281
288
  for plugin in plugins:
282
289
  models.register(*plugin.models, origin=plugin.name)
290
+ if root is not None:
291
+ models.register(*plugin.project_models(root), origin=plugin.name)
283
292
  return models.freeze()
284
293
 
285
294
 
@@ -294,7 +303,9 @@ def analyze_file(source: SourceFile, plugin_roots: Sequence[Path]) -> FileAnalys
294
303
 
295
304
  manager = _register_all(build_hir(source))
296
305
  registry = load_plugins(plugin_roots, manager)
297
- manager.provide(SecurityModelAnalysis, plugin_models(loaded.plugin for loaded in registry))
306
+ table = plugin_models(loaded.plugin for loaded in registry)
307
+ manager.provide(SecurityModelAnalysis, table)
308
+ manager.provide(ClearingAnalysis, table.clearing())
298
309
  manager.provide(ProjectSummaries, SummaryIndex())
299
310
  manager.provide(RegisteredRoutes, _routes_of(manager))
300
311
  findings, supported = _check_module(manager, tuple(loaded.plugin for loaded in registry))
@@ -385,7 +396,7 @@ def analyze_project(
385
396
  (a for plugin in all_plugins for a in plugin.advisories), file_advisories
386
397
  )
387
398
  affected = affected_symbols(dependencies, advisories)
388
- models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
399
+ models = plugin_models(all_plugins, root).extended(*advisory_sinks(affected))
389
400
  for manager in managers.values():
390
401
  manager.provide(SecurityModelAnalysis, models)
391
402
  manager.provide(DependencyAnalysis, dependencies)
@@ -408,9 +419,11 @@ def analyze_project(
408
419
  for symbol, registered in _routes_of(analysable[name]).items():
409
420
  routes.setdefault(symbol, registered)
410
421
  escaped = escaped_templates(root)
422
+ clearing = models.clearing(escaped)
411
423
  for manager in analysable.values():
412
424
  manager.provide(RegisteredRoutes, routes)
413
425
  manager.provide(EscapedTemplates, escaped)
426
+ manager.provide(ClearingAnalysis, clearing)
414
427
 
415
428
  configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes, escaped)
416
429
  keys = module_keys(
@@ -620,13 +633,15 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
620
633
  (a for plugin in all_plugins for a in plugin.advisories), file_advisories
621
634
  )
622
635
  affected = affected_symbols(dependencies, advisories)
623
- models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
636
+ models = plugin_models(all_plugins, batch.root).extended(*advisory_sinks(affected))
624
637
  routes = _decode_routes(batch.routes)
638
+ escaped = frozenset(batch.escaped)
625
639
  for manager in managers.values():
626
640
  manager.provide(SecurityModelAnalysis, models)
627
641
  manager.provide(DependencyAnalysis, dependencies)
628
642
  manager.provide(RegisteredRoutes, routes)
629
- manager.provide(EscapedTemplates, frozenset(batch.escaped))
643
+ manager.provide(EscapedTemplates, escaped)
644
+ manager.provide(ClearingAnalysis, models.clearing(escaped))
630
645
  module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
631
646
  results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
632
647
  return {name: encode(entry) for name, entry in results.items()}
@@ -29,7 +29,12 @@ from coretrace_python.abstract import ConstantPropagation, RangeAnalysis, RangeF
29
29
  from coretrace_python.analysis import AnalysisContext, AnyAnalysis, FunctionAnalysis
30
30
  from coretrace_python.cfg import CFG, BlockId, CFGAnalysis, DominanceAnalysis, DominatorTree
31
31
  from coretrace_python.hir import nodes
32
- from coretrace_python.interprocedural import CallGraphAnalysis
32
+ from coretrace_python.interprocedural import (
33
+ CallGraph,
34
+ CallGraphAnalysis,
35
+ KnownFunction,
36
+ project_symbol,
37
+ )
33
38
  from coretrace_python.ir.model import (
34
39
  BoolOp,
35
40
  Branch,
@@ -55,6 +60,7 @@ from coretrace_python.taint import (
55
60
  TaintAnalysis,
56
61
  TaintFacts,
57
62
  TaintFlow,
63
+ TaintKind,
58
64
  )
59
65
 
60
66
  VALIDATORS = frozenset(
@@ -214,15 +220,16 @@ class _Judge:
214
220
 
215
221
  def interpret(
216
222
  self, condition: Value, truth: bool | None
217
- ) -> tuple[dict[Value, str], set[Value]]:
218
- """Values the condition proves safe (with the reason), and values it mentions
219
- without proving anything. ``truth`` is ``None`` when it is not fixed at the sink.
223
+ ) -> tuple[dict[Value, tuple[str, TaintKind]], set[Value]]:
224
+ """Values the condition proves safe (with the reason and the kinds it proves them
225
+ safe for), and values it mentions without proving anything. ``truth`` is ``None``
226
+ when it is not fixed at the sink.
220
227
 
221
228
  A recognised check evaluated the wrong way (``isdigit()`` known false) yields
222
229
  nothing: it is neither a proof nor a reassuring guard."""
223
230
 
224
231
  definition = self.defs.get(condition)
225
- proven: dict[Value, str] = {}
232
+ proven: dict[Value, tuple[str, TaintKind]] = {}
226
233
  mentioned: set[Value] = set()
227
234
  if isinstance(definition, UnaryOp) and definition.operator == "not":
228
235
  return self.interpret(definition.operand, None if truth is None else not truth)
@@ -236,27 +243,29 @@ class _Judge:
236
243
  recognised = self.recognise(definition)
237
244
  if recognised is None:
238
245
  return proven, set(self.closure(condition)) | {condition}
239
- tested, reason, when_true = recognised
246
+ tested, reason, when_true, kinds = recognised
240
247
  if truth is None:
241
248
  mentioned = set(self.closure(condition)) | {condition}
242
249
  elif truth == when_true:
243
250
  # The check proves ``tested``; whatever else it reads is merely mentioned.
244
- proven[tested] = reason
251
+ proven[tested] = (reason, kinds)
245
252
  mentioned = set(self.closure(condition)) | {condition}
246
253
  return proven, mentioned
247
254
 
248
- def recognise(self, definition: Instruction | None) -> tuple[Value, str, bool] | None:
249
- """``(validated value, reason, truth that validates)`` for known check shapes."""
255
+ def recognise(self, definition: Instruction | None) -> tuple[Value, str, bool, TaintKind] | None:
256
+ """``(validated value, reason, truth that validates, kinds it proves)`` for known
257
+ check shapes. A string check, a constant allowlist or an equality with a constant
258
+ proves every kind; a ``Validator`` only the kinds it declares."""
250
259
 
251
260
  if isinstance(definition, Call) and not definition.arguments:
252
261
  callee = self.defs.get(definition.callee)
253
262
  if isinstance(callee, GetAttr) and callee.attribute in VALIDATORS:
254
- return callee.object, f"guarded by {callee.attribute}()", True
263
+ return callee.object, f"guarded by {callee.attribute}()", True, TaintKind.ALL
255
264
  if isinstance(definition, Call):
256
265
  symbol = self.symbols.get(definition.callee)
257
266
  validator = self.models.validator(symbol) if symbol is not None else None
258
267
  if validator is not None and validator.argument < len(definition.arguments):
259
- return definition.arguments[validator.argument], f"validated by {symbol}", True
268
+ return definition.arguments[validator.argument], f"validated by {symbol}", True, validator.kinds
260
269
  if isinstance(definition, Compare):
261
270
  left, right = definition.left, definition.right
262
271
  if definition.operator in ("in", "not_in") and self.is_constant_collection(right):
@@ -264,11 +273,12 @@ class _Judge:
264
273
  left,
265
274
  "allowlisted by a membership check on constants",
266
275
  definition.operator == "in",
276
+ TaintKind.ALL,
267
277
  )
268
278
  if definition.operator in ("eq", "not_eq"):
269
279
  for tested, other in ((left, right), (right, left)):
270
280
  if isinstance(self.defs.get(other), Constant):
271
- return tested, "equals a constant", definition.operator == "eq"
281
+ return tested, "equals a constant", definition.operator == "eq", TaintKind.ALL
272
282
  return None
273
283
 
274
284
  def examined(
@@ -387,8 +397,9 @@ class _Judge:
387
397
  )
388
398
  for guard in self.guards(sink):
389
399
  found, mentioned = self.interpret(guard.condition, guard.truth)
390
- for value, reason in found.items():
391
- if value in chain:
400
+ for value, (reason, kinds) in found.items():
401
+ # A check proving some kinds only leaves the others reaching the sink.
402
+ if value in chain and not flow.kinds & ~kinds:
392
403
  proven.setdefault(value, reason)
393
404
  examined = self.examined(guard.condition, mentioned, flow.argument, chain)
394
405
  for origin in origins:
@@ -478,6 +489,7 @@ class RefutationAnalysis(FunctionAnalysis[Verdicts]):
478
489
  constants = ctx.get(ConstantPropagation, function)
479
490
  graph = ctx.get(CallGraphAnalysis)
480
491
  models = ctx.get(SecurityModelAnalysis)
492
+ name = graph.name_of(function)
481
493
  return judge_flows(
482
494
  ssa,
483
495
  ctx.get(CFGAnalysis, function),
@@ -486,7 +498,21 @@ class RefutationAnalysis(FunctionAnalysis[Verdicts]):
486
498
  frozenset(b.id for b in ssa.blocks if constants.reachable(b.id)),
487
499
  ctx.get(RangeAnalysis, function),
488
500
  models,
489
- graph.symbols(graph.name_of(function)),
501
+ _with_project_callees(ssa, graph, name, ctx.module.name),
490
502
  authorization_of(function, models, ctx.get(ScopeAnalysis), ctx.get(SymbolAnalysis)),
491
503
  )
492
504
 
505
+
506
+ def _with_project_callees(function: FunctionIR, graph: CallGraph, name: str, module: str) -> dict[Value, SymbolId]:
507
+ """The symbols of ``name``'s values, with each call to a function of its module named
508
+ by its project symbol, so a model declared on it (``Validator``) applies there too."""
509
+
510
+ symbols = dict(graph.symbols(name))
511
+ for block in function.blocks:
512
+ for instruction in block.instructions:
513
+ if isinstance(instruction, Call):
514
+ target = graph.target_at(name, instruction.location)
515
+ if isinstance(target, KnownFunction):
516
+ symbols.setdefault(instruction.callee, project_symbol(module, target.name))
517
+ return symbols
518
+
@@ -20,6 +20,9 @@ from coretrace_python.interprocedural.modulegraph import (
20
20
  project_symbol,
21
21
  )
22
22
  from coretrace_python.interprocedural.summaries import (
23
+ Cleared,
24
+ Clearing,
25
+ ClearingAnalysis,
23
26
  ExternalCall,
24
27
  FunctionSummary,
25
28
  Mutation,
@@ -28,6 +31,7 @@ from coretrace_python.interprocedural.summaries import (
28
31
  SummaryAnalysis,
29
32
  SummaryIndex,
30
33
  SummaryTable,
34
+ cleared_by,
31
35
  )
32
36
 
33
37
  __all__ = [
@@ -35,6 +39,9 @@ __all__ = [
35
39
  "CallGraph",
36
40
  "CallGraphAnalysis",
37
41
  "CallSite",
42
+ "Cleared",
43
+ "Clearing",
44
+ "ClearingAnalysis",
38
45
  "ExternalCall",
39
46
  "ExternalSymbol",
40
47
  "FunctionSummary",
@@ -51,6 +58,7 @@ __all__ = [
51
58
  "Target",
52
59
  "UnknownTarget",
53
60
  "build_module_graph",
61
+ "cleared_by",
54
62
  "discover_files",
55
63
  "discover_sources",
56
64
  "project_symbol",
@@ -4,16 +4,19 @@ A summary says which parameters a function's return value depends on and which e
4
4
  symbols its parameters reach, directly or through calls to other known functions. It is
5
5
  computed as a dependence data-flow problem over the SSA form and iterated to a fixpoint
6
6
  over the call graph, so recursion converges to the least solution. Summaries carry no
7
- security knowledge: the taint engine decides which external symbols matter.
7
+ security knowledge of their own: the taint engine decides which external symbols
8
+ matter. They only keep, per parameter, the bits a ``Clearing`` the engine provides says
9
+ the calls on the way cleared, on every path, so a caller's data reaches a sink or comes
10
+ back without the kinds a sanitizer inside the function removed.
8
11
  """
9
12
 
10
13
  from __future__ import annotations
11
14
 
12
15
  import dataclasses
13
- from collections.abc import Mapping
16
+ from collections.abc import Iterable, Mapping
14
17
  from dataclasses import dataclass
15
18
  from types import MappingProxyType
16
- from typing import ClassVar
19
+ from typing import ClassVar, TypeVar
17
20
 
18
21
  from coretrace_python.abstract import (
19
22
  ATTRIBUTES,
@@ -69,22 +72,90 @@ from coretrace_python.source import SourceSpan
69
72
 
70
73
  Dependencies = frozenset[int]
71
74
  NONE: Dependencies = frozenset()
75
+ _T = TypeVar("_T")
76
+ # Per parameter, the taint kind bits cleared on every path from it, sorted, none empty.
77
+ Cleared = tuple[tuple[int, int], ...]
72
78
 
73
79
 
74
80
  @dataclass(frozen=True)
75
81
  class Dep:
76
- """What a value depends on: parameter indices and results of external symbols."""
82
+ """What a value depends on: parameter indices, results of external symbols, and for
83
+ each parameter the bits cleared on every path from it to the value."""
77
84
 
78
85
  parameters: Dependencies = NONE
79
86
  externals: frozenset[SymbolId] = frozenset()
87
+ cleared: Cleared = ()
80
88
 
81
89
  def __or__(self, other: Dep) -> Dep:
82
- return Dep(self.parameters | other.parameters, self.externals | other.externals)
90
+ parameters = self.parameters | other.parameters
91
+ if not self.cleared and not other.cleared:
92
+ return Dep(parameters, self.externals | other.externals)
93
+ own, theirs = dict(self.cleared), dict(other.cleared)
94
+ # A parameter both sides depend on keeps what both paths cleared.
95
+ cleared = {
96
+ p: (own.get(p, 0) & theirs.get(p, 0))
97
+ if p in self.parameters and p in other.parameters
98
+ else own.get(p, 0) | theirs.get(p, 0)
99
+ for p in parameters
100
+ }
101
+ return Dep(parameters, self.externals | other.externals, _cleared(cleared))
102
+
103
+ def clearing(self, bits: int) -> Dep:
104
+ """This dependence after a call clearing ``bits`` from what it returns."""
105
+
106
+ if not bits or not self.parameters:
107
+ return self
108
+ own = dict(self.cleared)
109
+ return Dep(self.parameters, self.externals, _cleared({p: own.get(p, 0) | bits for p in self.parameters}))
110
+
111
+
112
+ def _cleared(bits: Mapping[int, int]) -> Cleared:
113
+ return tuple(sorted((p, b) for p, b in bits.items() if b))
114
+
115
+
116
+ def cleared_by(cleared: Cleared, parameter: int) -> int:
117
+ """The bits ``cleared`` records for ``parameter``, none when it has no entry."""
118
+
119
+ return next((bits for p, bits in cleared if p == parameter), 0)
83
120
 
84
121
 
85
122
  EMPTY = Dep()
86
123
 
87
124
 
125
+ @dataclass(frozen=True)
126
+ class Clearing:
127
+ """What an external call clears from the data it returns, as taint kind bits the
128
+ summaries carry without reading them. The engine derives it from the security models:
129
+ a sanitizer clears its kinds; a template render clears the bits of its entry when the
130
+ template it names is one the project shows escaping (``escaped`` holds the names the
131
+ way call sites record a constant)."""
132
+
133
+ sanitizers: Mapping[SymbolId, int] = dataclasses.field(default_factory=dict)
134
+ renders: Mapping[SymbolId, tuple[int, str, int]] = dataclasses.field(default_factory=dict)
135
+ escaped: frozenset[str] = frozenset()
136
+
137
+ def clears(self, symbol: SymbolId, arguments: Arguments) -> int:
138
+ bits = self.sanitizers.get(symbol, 0)
139
+ render = self.renders.get(symbol)
140
+ if render is not None:
141
+ position, keyword, rendered = render
142
+ named = arguments.given(keyword, position)
143
+ if named is not None and named[0] and named[1] in self.escaped:
144
+ bits |= rendered
145
+ return bits
146
+
147
+
148
+ class ClearingAnalysis(Analysis[Clearing]):
149
+ """What calls clear, provided by the engine from the security models; nothing on its
150
+ own, so a summary then keeps every kind."""
151
+
152
+ name: ClassVar[str] = "interprocedural.clearing"
153
+
154
+ @classmethod
155
+ def compute(cls, ctx: AnalysisContext) -> Clearing:
156
+ return Clearing()
157
+
158
+
88
159
  @dataclass(frozen=True)
89
160
  class ExternalCall:
90
161
  """An external symbol reached from this function; dependencies are parameter indices.
@@ -98,6 +169,19 @@ class ExternalCall:
98
169
  call_site: SourceSpan | None
99
170
  # What the arguments of the call at ``location`` denote, where it is made.
100
171
  arguments: Arguments = dataclasses.field(default_factory=Arguments)
172
+ # What was cleared on the way to each argument and keyword, in the same order.
173
+ argument_cleared: tuple[Cleared, ...] = ()
174
+ keyword_cleared: tuple[Cleared, ...] = ()
175
+
176
+ def positional(self) -> Iterable[tuple[Dependencies, Cleared]]:
177
+ """Each positional argument's dependencies, with what was cleared on their way."""
178
+
179
+ return _aligned(self.argument_dependencies, self.argument_cleared)
180
+
181
+ def named(self) -> Iterable[tuple[str | None, Dependencies, Cleared]]:
182
+ """Each keyword's name and dependencies, with what was cleared on their way."""
183
+
184
+ return ((name, deps, c) for (name, deps), c in _aligned(self.keyword_dependencies, self.keyword_cleared))
101
185
 
102
186
 
103
187
  @dataclass(frozen=True)
@@ -134,6 +218,8 @@ class FunctionSummary:
134
218
  nonlocal_writes: tuple[NonlocalWrite, ...] = ()
135
219
  # A ``@staticmethod``: ``Class.method(...)`` passes no receiver.
136
220
  static: bool = False
221
+ # What was cleared on the way from each parameter to the return value.
222
+ return_cleared: Cleared = ()
137
223
 
138
224
 
139
225
  class SummaryIndex:
@@ -191,6 +277,7 @@ class _DependenceProblem(DataflowProblem[State]):
191
277
  table: Mapping[str, FunctionSummary],
192
278
  project: Mapping[SymbolId, FunctionSummary] | None = None,
193
279
  heap: HeapFacts | None = None,
280
+ clearing: Clearing | None = None,
194
281
  ) -> None:
195
282
  self.name = name
196
283
  self.function = function
@@ -198,6 +285,7 @@ class _DependenceProblem(DataflowProblem[State]):
198
285
  self.table = table
199
286
  self.project = project or {}
200
287
  self.heap = heap or HeapFacts({})
288
+ self.clearing = clearing or Clearing()
201
289
  self.blocks = {block.id: block for block in function.blocks}
202
290
  self.defs: dict[Value, Instruction] = {
203
291
  i.result: i for block in function.blocks for i in block.instructions if i.result
@@ -338,15 +426,10 @@ class _DependenceProblem(DataflowProblem[State]):
338
426
  bound = () if project.static else self.receiver(call, project.name)
339
427
  arguments = (*(self.deep(r, state) for r in bound), *arguments)
340
428
  return self.known(project, arguments, keywords, everything, call, state, bound)
341
- self.record(
342
- target.symbol,
343
- tuple(a.parameters for a in arguments),
344
- tuple((name, deps.parameters) for name, deps in named),
345
- call.location,
346
- None,
347
- self.graph.arguments_at(self.name, call.location),
348
- )
349
- return everything | Dep(externals=frozenset({target.symbol}))
429
+ given = self.graph.arguments_at(self.name, call.location)
430
+ self.record(target.symbol, arguments, named, call.location, None, given)
431
+ cleared = everything.clearing(self.clearing.clears(target.symbol, given))
432
+ return cleared | Dep(externals=frozenset({target.symbol}))
350
433
  if isinstance(target, KnownFunction):
351
434
  made = self.defs.get(call.callee)
352
435
  if isinstance(made, MakeFunction):
@@ -414,17 +497,18 @@ class _DependenceProblem(DataflowProblem[State]):
414
497
  if callee.unsupported:
415
498
  return everything
416
499
 
417
- def mapped(indices: Dependencies) -> Dep:
500
+ def mapped(indices: Dependencies, cleared: Cleared = ()) -> Dep:
418
501
  result = EMPTY
419
502
  for index in indices:
420
- result |= arguments[index] if index < len(arguments) else keywords
503
+ part = arguments[index] if index < len(arguments) else keywords
504
+ result |= part.clearing(cleared_by(cleared, index))
421
505
  return result
422
506
 
423
507
  for reached in callee.external_calls:
424
508
  self.record(
425
509
  reached.symbol,
426
- tuple(mapped(d).parameters for d in reached.argument_dependencies),
427
- tuple((name, mapped(d).parameters) for name, d in reached.keyword_dependencies),
510
+ tuple(mapped(d, c) for d, c in reached.positional()),
511
+ tuple((name, mapped(d, c)) for name, d, c in reached.named()),
428
512
  reached.location,
429
513
  call.location,
430
514
  reached.arguments,
@@ -435,13 +519,13 @@ class _DependenceProblem(DataflowProblem[State]):
435
519
  if mutation.parameter < len(values):
436
520
  deps = mapped(mutation.dependencies) | Dep(externals=mutation.externals)
437
521
  self.store(state, values[mutation.parameter], mutation.field, deps)
438
- return mapped(callee.return_dependencies) | Dep(externals=callee.return_externals)
522
+ return mapped(callee.return_dependencies, callee.return_cleared) | Dep(externals=callee.return_externals)
439
523
 
440
524
  def record(
441
525
  self,
442
526
  symbol: SymbolId,
443
- arguments: tuple[Dependencies, ...],
444
- keywords: tuple[tuple[str | None, Dependencies], ...],
527
+ arguments: tuple[Dep, ...],
528
+ keywords: tuple[tuple[str | None, Dep], ...],
445
529
  location: SourceSpan,
446
530
  call_site: SourceSpan | None,
447
531
  given: Arguments,
@@ -450,14 +534,30 @@ class _DependenceProblem(DataflowProblem[State]):
450
534
  previous = self.external.get(key)
451
535
  if previous is not None:
452
536
  arguments = tuple(
453
- a | b for a, b in zip(previous.argument_dependencies, arguments, strict=False)
537
+ a | Dep(b, cleared=c) for a, (b, c) in zip(arguments, previous.positional(), strict=False)
454
538
  )
455
539
  # The same call records the same keywords, in the same order.
456
540
  keywords = tuple(
457
- (name, deps | old)
458
- for (name, deps), (_, old) in zip(keywords, previous.keyword_dependencies, strict=False)
541
+ (name, deps | Dep(old, cleared=c))
542
+ for (name, deps), (_, old, c) in zip(keywords, previous.named(), strict=False)
459
543
  )
460
- self.external[key] = ExternalCall(symbol, arguments, keywords, location, call_site, given)
544
+ self.external[key] = ExternalCall(
545
+ symbol,
546
+ tuple(a.parameters for a in arguments),
547
+ tuple((name, deps.parameters) for name, deps in keywords),
548
+ location,
549
+ call_site,
550
+ given,
551
+ tuple(a.cleared for a in arguments),
552
+ tuple(deps.cleared for _, deps in keywords),
553
+ )
554
+
555
+
556
+ def _aligned(dependencies: tuple[_T, ...], cleared: tuple[Cleared, ...]) -> Iterable[tuple[_T, Cleared]]:
557
+ """Each dependency with what was cleared on its way; nothing when not recorded."""
558
+
559
+ missing = len(dependencies) - len(cleared)
560
+ return zip(dependencies, (*cleared, *(() for _ in range(missing))), strict=True)
461
561
 
462
562
 
463
563
  def summarize(
@@ -469,8 +569,9 @@ def summarize(
469
569
  project: Mapping[SymbolId, FunctionSummary] | None = None,
470
570
  heap: HeapFacts | None = None,
471
571
  static: bool = False,
572
+ clearing: Clearing | None = None,
472
573
  ) -> FunctionSummary:
473
- problem = _DependenceProblem(name, function, graph, table, project, heap)
574
+ problem = _DependenceProblem(name, function, graph, table, project, heap, clearing)
474
575
  solution = solve(problem, cfg)
475
576
  problem.external = {}
476
577
  problem.returns = EMPTY
@@ -502,19 +603,21 @@ def summarize(
502
603
  if deps.parameters or deps.externals
503
604
  ),
504
605
  static=static,
606
+ return_cleared=problem.returns.cleared,
505
607
  )
506
608
 
507
609
 
508
610
  class SummaryAnalysis(Analysis[SummaryTable]):
509
611
  name: ClassVar[str] = "interprocedural.summaries"
510
612
  requires: ClassVar[frozenset[AnyAnalysis]] = frozenset(
511
- {CallGraphAnalysis, SSAAnalysis, CFGAnalysis, ProjectSummaries, HeapAnalysis}
613
+ {CallGraphAnalysis, SSAAnalysis, CFGAnalysis, ProjectSummaries, HeapAnalysis, ClearingAnalysis}
512
614
  )
513
615
 
514
616
  @classmethod
515
617
  def compute(cls, ctx: AnalysisContext) -> SummaryTable:
516
618
  graph = ctx.get(CallGraphAnalysis)
517
619
  project = ctx.get(ProjectSummaries).summaries
620
+ clearing = ctx.get(ClearingAnalysis)
518
621
  table: dict[str, FunctionSummary] = {}
519
622
  supported: dict[str, tuple[FunctionIR, CFG, HeapFacts]] = {}
520
623
  for name, function in graph.definitions.items():
@@ -533,7 +636,7 @@ class SummaryAnalysis(Analysis[SummaryTable]):
533
636
  changed = False
534
637
  for name, (ssa, cfg, heap) in supported.items():
535
638
  updated = summarize(
536
- name, ssa, cfg, graph, table, project, heap, _is_staticmethod(graph.definitions[name])
639
+ name, ssa, cfg, graph, table, project, heap, _is_staticmethod(graph.definitions[name]), clearing
537
640
  )
538
641
  if updated != table[name]:
539
642
  table[name] = updated
@@ -51,6 +51,14 @@ class Plugin(ABC):
51
51
  def analyze(self, ctx: PluginContext) -> Sequence[Finding]:
52
52
  raise NotImplementedError
53
53
 
54
+ def project_models(self, root: Path) -> Sequence[Model]:
55
+ """Models read from the project under ``root``, such as validators it declares
56
+ in a file there. Called once per directory check, in every process analysing
57
+ it, before any module; a single-file check reads no project. A model declared
58
+ wrongly raises ``ModelError``, which stops the check with its message."""
59
+
60
+ return ()
61
+
54
62
 
55
63
  class ModelPlugin(Plugin):
56
64
  """A plugin that only contributes security models or advisories (§15 providers)."""
@@ -9,7 +9,7 @@ argument reaching a sink whose kinds it still carries is reported as a ``TaintFl
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from collections.abc import Callable, Mapping
12
+ from collections.abc import Callable, Iterable, Mapping
13
13
  from dataclasses import dataclass, replace
14
14
  from types import MappingProxyType
15
15
  from typing import ClassVar
@@ -30,6 +30,8 @@ from coretrace_python.interprocedural import (
30
30
  Arguments,
31
31
  CallGraph,
32
32
  CallGraphAnalysis,
33
+ Cleared,
34
+ Clearing,
33
35
  ExternalSymbol,
34
36
  FunctionSummary,
35
37
  KnownFunction,
@@ -38,6 +40,7 @@ from coretrace_python.interprocedural import (
38
40
  SummaryAnalysis,
39
41
  SummaryIndex,
40
42
  SummaryTable,
43
+ cleared_by,
41
44
  project_symbol,
42
45
  )
43
46
  from coretrace_python.ir.lowering import analyzable_functions, is_body_function
@@ -116,6 +119,9 @@ class TaintFlow:
116
119
  sink_location: SourceSpan | None = None
117
120
  # What the arguments of the sink call denote, to decide the conditions on them.
118
121
  sink_arguments: Arguments | None = None
122
+ # How the tainted value is passed to the sink call: at a position, by a keyword, or
123
+ # (None, None) unpacked with ``*`` or ``**``; ``f(x, key=x)`` passes it twice.
124
+ passed_as: frozenset[tuple[int | None, str | None]] = frozenset()
119
125
 
120
126
 
121
127
  Key = Value | HeapLocation
@@ -153,12 +159,11 @@ class _TaintProblem(DataflowProblem[State]):
153
159
  heap: HeapFacts | None = None,
154
160
  seeds: Mapping[HeapLocation, Taint] | None = None,
155
161
  module: str = "",
156
- escaped: frozenset[str] = frozenset(),
162
+ clearing: Clearing | None = None,
157
163
  ) -> None:
158
164
  self.name = name
159
165
  self.module = module
160
- # As call sites record a constant argument: the way Python writes it.
161
- self.escaped = frozenset(repr(name) for name in escaped)
166
+ self.clearing = clearing or Clearing()
162
167
  self.function = function
163
168
  self.models = models
164
169
  self.graph = graph
@@ -411,11 +416,9 @@ class _TaintProblem(DataflowProblem[State]):
411
416
  self.report(
412
417
  flows, sink, self.rendered(argument, state), argument, call, None, None, None, given, keyword=name
413
418
  )
414
- sanitizer = self.models.sanitizer(symbol)
415
- if sanitizer is not None:
416
- return everything.without(sanitizer.kinds)
417
- if self.renders_escaped(symbol, given):
418
- return everything.without(TaintKind.HTML)
419
+ cleared = self.clearing.clears(symbol, given)
420
+ if cleared:
421
+ return everything.without(TaintKind(cleared))
419
422
  # A method on a tainted object returns tainted data (``request.args.get``).
420
423
  everything = everything.join(state.get(call.callee, Taint.none()))
421
424
  source = self.models.source_covering(symbol)
@@ -423,14 +426,6 @@ class _TaintProblem(DataflowProblem[State]):
423
426
  return everything.join(Taint(source.kinds, frozenset({source})))
424
427
  return everything
425
428
 
426
- def renders_escaped(self, symbol: SymbolId, given: Arguments) -> bool:
427
- """Whether the call renders, by a name written in the call, a template the
428
- project shows escaping everything it renders."""
429
-
430
- render = self.models.template_render(symbol)
431
- named = given.given(render.keyword, render.position) if render is not None else None
432
- return named is not None and named[0] and named[1] in self.escaped
433
-
434
429
  def guarded(self, sink: Sink | None, arguments: Arguments) -> Sink | None:
435
430
  """The sink as this call makes it: an argument declared safe (``yaml.load`` with
436
431
  ``Loader=SafeLoader``) takes off the kinds it covers, and no kind left, no sink."""
@@ -501,11 +496,11 @@ class _TaintProblem(DataflowProblem[State]):
501
496
  spread = (*call.starred, *(value for _, value in call.keywords))
502
497
  values = (*receiver, *call.arguments, *captured)
503
498
 
504
- def mapped(deps: frozenset[int]) -> tuple[Taint, Value | None]:
499
+ def mapped(deps: frozenset[int], cleared: Cleared = ()) -> tuple[Taint, Value | None]:
505
500
  taint, witness = Taint.none(), None
506
501
  for index in sorted(deps):
507
502
  positional = index < len(arguments)
508
- part = arguments[index] if positional else keywords
503
+ part = (arguments[index] if positional else keywords).without(TaintKind(cleared_by(cleared, index)))
509
504
  if part and witness is None and (positional or spread):
510
505
  witness = values[index] if positional else spread[0]
511
506
  taint = taint.join(part)
@@ -515,12 +510,12 @@ class _TaintProblem(DataflowProblem[State]):
515
510
  sink = self.guarded(self.models.sink(reached.symbol), reached.arguments)
516
511
  if sink is None:
517
512
  continue
518
- for position, deps in enumerate(reached.argument_dependencies):
519
- taint, witness = mapped(deps)
513
+ for position, (deps, cleared) in enumerate(reached.positional()):
514
+ taint, witness = mapped(deps, cleared)
520
515
  if witness is not None:
521
516
  self.report(flows, sink, taint, witness, call, through, reached.location, position, reached.arguments)
522
- for name, deps in reached.keyword_dependencies:
523
- taint, witness = mapped(deps)
517
+ for name, deps, cleared in reached.named():
518
+ taint, witness = mapped(deps, cleared)
524
519
  if witness is not None:
525
520
  self.report(
526
521
  flows, sink, taint, witness, call, through, reached.location, None, reached.arguments, keyword=name
@@ -533,7 +528,7 @@ class _TaintProblem(DataflowProblem[State]):
533
528
  if stored_source is not None:
534
529
  stored = stored.join(Taint(stored_source.kinds, frozenset({stored_source})))
535
530
  self.store(state, values[mutation.parameter], mutation.field, stored)
536
- result = mapped(summary.return_dependencies)[0]
531
+ result = mapped(summary.return_dependencies, summary.return_cleared)[0]
537
532
  for symbol in sorted(summary.return_externals, key=str):
538
533
  returned_source = self.models.source(symbol)
539
534
  if returned_source is not None:
@@ -567,6 +562,7 @@ class _TaintProblem(DataflowProblem[State]):
567
562
  through,
568
563
  call.location if sink_location is None else sink_location,
569
564
  arguments,
565
+ frozenset({(position, keyword)}),
570
566
  )
571
567
  )
572
568
 
@@ -816,9 +812,9 @@ def propagate_taint(
816
812
  heap: HeapFacts | None = None,
817
813
  seeds: Mapping[HeapLocation, Taint] | None = None,
818
814
  module: str = "",
819
- escaped: frozenset[str] = frozenset(),
815
+ clearing: Clearing | None = None,
820
816
  ) -> TaintFacts:
821
- problem = _TaintProblem(name, function, models, graph, summaries, parameters, project, heap, seeds, module, escaped)
817
+ problem = _TaintProblem(name, function, models, graph, summaries, parameters, project, heap, seeds, module, clearing)
822
818
  solution = solve(problem, cfg)
823
819
  taints: dict[Key, Taint] = {}
824
820
  flows: list[TaintFlow] = []
@@ -827,7 +823,18 @@ def propagate_taint(
827
823
  state, found = problem.evaluate(block, solution.incoming(block.id))
828
824
  taints.update(state)
829
825
  flows.extend(found)
830
- return TaintFacts(taints, tuple(dict.fromkeys(flows)))
826
+ return TaintFacts(taints, _merged(flows))
827
+
828
+
829
+ def _merged(flows: Iterable[TaintFlow]) -> tuple[TaintFlow, ...]:
830
+ """One flow per source, sink, kinds and tainted value, however many arguments of the
831
+ sink call it is passed as."""
832
+
833
+ passed: dict[TaintFlow, frozenset[tuple[int | None, str | None]]] = {}
834
+ for flow in flows:
835
+ key = replace(flow, passed_as=frozenset())
836
+ passed[key] = passed.get(key, frozenset()) | flow.passed_as
837
+ return tuple(replace(flow, passed_as=ways) for flow, ways in passed.items())
831
838
 
832
839
 
833
840
  class TaintAnalysis(FunctionAnalysis[TaintFacts]):
@@ -883,7 +890,7 @@ class TaintAnalysis(FunctionAnalysis[TaintFacts]):
883
890
  heap,
884
891
  seeds,
885
892
  ctx.module.name,
886
- ctx.get(EscapedTemplates),
893
+ models.clearing(ctx.get(EscapedTemplates)),
887
894
  )
888
895
 
889
896
 
@@ -16,6 +16,7 @@ from types import MappingProxyType
16
16
  from typing import ClassVar
17
17
 
18
18
  from coretrace_python.analysis import Analysis, AnalysisContext, MissingInputError
19
+ from coretrace_python.interprocedural import Clearing
19
20
  from coretrace_python.semantic.symbols import SymbolId
20
21
 
21
22
 
@@ -334,6 +335,17 @@ class ModelTable:
334
335
  found = self._by_symbol[TemplateRender].get(symbol)
335
336
  return found if isinstance(found, TemplateRender) else None
336
337
 
338
+ def clearing(self, escaped: frozenset[str] = frozenset()) -> Clearing:
339
+ """What calls clear from the data they return: every sanitizer its kinds, every
340
+ template render ``HTML`` when it names one of the ``escaped`` templates."""
341
+
342
+ return Clearing(
343
+ MappingProxyType({s.symbol: s.kinds.value for s in self.sanitizers}),
344
+ MappingProxyType({r.symbol: (r.position, r.keyword, TaintKind.HTML.value) for r in self.template_renders}),
345
+ # As call sites record a constant argument: the way Python writes it.
346
+ frozenset(repr(name) for name in escaped),
347
+ )
348
+
337
349
 
338
350
  class SecurityModelRegistry:
339
351
  """Mutable collection point for the models plugins register."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coretrace-python-analyzer
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Static security analysis infrastructure for Python code
5
5
  Project-URL: Homepage, https://github.com/CoreTrace/coretrace-python-analyzer
6
6
  Project-URL: Repository, https://github.com/CoreTrace/coretrace-python-analyzer
@@ -1,8 +1,8 @@
1
- coretrace_python/__init__.py,sha256=kEx6lE06VX-mfQ6o7DrayGZNlR2uTGSdGItAWsSIeHI,75
1
+ coretrace_python/__init__.py,sha256=JoQcl29Ph_JPstj_FTAA1vDAxIylnVKy9661tvKlYIo,75
2
2
  coretrace_python/__main__.py,sha256=V8DnYtRboISK2VCKm7DGHLREKvqLbqApyWlKBOs26wU,65
3
- coretrace_python/cache.py,sha256=tt-l1jOibCx0lNKGd7XF_xTlcCeBLewPWOPpS-e3Ynk,12929
4
- coretrace_python/cli.py,sha256=SwXYUR8DIgppkqom6kKNM701pb4p2omEy-hRXIM5yZE,10946
5
- coretrace_python/engine.py,sha256=3NxNKzkNTWxa7a4NVkZ0fyh5KitNpDHuTsCrdBa4VxU,28696
3
+ coretrace_python/cache.py,sha256=-pZv-ELm4jMCTXQWSZIobHaT9UHIjFu7IlMkOM0i4LI,13607
4
+ coretrace_python/cli.py,sha256=86rTb3Kcezwk9DBTRg-G3G38ypx8zH4lZbCR467xvpE,11008
5
+ coretrace_python/engine.py,sha256=VWTUTgm9k0DrPYhYrgJQHzXi_L00pxnH8fGfo8insMc,29340
6
6
  coretrace_python/abstract/__init__.py,sha256=n5FNkV8Z4LmwG0r06KWdLj4N2CPSgAKwY3896tdmRgM,999
7
7
  coretrace_python/abstract/constants.py,sha256=z4-e7IzysxzGi58wFFff1MlEoV8l7AZsB9EL-q6b7vY,9071
8
8
  coretrace_python/abstract/heap.py,sha256=OaESXFGC0qzyhAr76xrCu4skeDVzZM4wlenQqfMTkdk,9642
@@ -88,10 +88,10 @@ coretrace_python/cfg/model.py,sha256=6EJiXFAGrsMgCdyxMJ6TClY8tQhJF6Hq2bX802Gc0yg
88
88
  coretrace_python/dataflow/__init__.py,sha256=N5uY8VTNjEk6-ARCgDkxr6uGqQIz2NbF-U9sC_v_lfA,547
89
89
  coretrace_python/dataflow/lattice.py,sha256=HgYGVcKVUlexvEc0yT47aQCudLxU_SWLpHhr17igkiE,1929
90
90
  coretrace_python/dataflow/solver.py,sha256=iDsW0wujhM2DXplu2JMKC46BRsroXlNETHPEoikPeG4,3475
91
- coretrace_python/dependency/__init__.py,sha256=CXE36X2d5rio90NWlpjLo8Hly-tmzDCvu05FZAj_WXQ,1138
92
- coretrace_python/dependency/advisories.py,sha256=tfR-5BmjV4GYcsF3CSGA4jSXNwOVG6mS8AqPfy2fWms,8653
93
- coretrace_python/dependency/correlation.py,sha256=pR9S0tcdumdk2DMfUXxz6Wdul_RZE8FBfVmCUEhcA7w,7231
94
- coretrace_python/dependency/graph.py,sha256=6UpAsqbtr5EoVGp6qkaQ7Gi1iUbGtulyh0hfpK_RqLg,16875
91
+ coretrace_python/dependency/__init__.py,sha256=BxkxnNwIEpAYtwbshCQovG-YXJ80QbwyzkmyJdbgsVg,1184
92
+ coretrace_python/dependency/advisories.py,sha256=zti7MrojcfxfYWJuGpyLXXJU3pffpT4SJnQUa_g34Lw,10274
93
+ coretrace_python/dependency/correlation.py,sha256=GLtrMn5yYr4NcKYNYZTqIFpwoXOrgsqI9t6lggXxFbg,7388
94
+ coretrace_python/dependency/graph.py,sha256=HtvKeWWG9cy9TzJQ7lYMGPfHjPBxoSVgwzoj71SegaE,18445
95
95
  coretrace_python/dependency/policy.py,sha256=bsxZXL9xC0jnAzkzDh6ncnW8oKLG2H13jYmXF_q99Ng,2454
96
96
  coretrace_python/dependency/sbom.py,sha256=h4K3Gb9O_D6uZyfpwpC5rPcNjH_Ib-3uvFxix6OKlfs,2520
97
97
  coretrace_python/dependency/vex.py,sha256=ZPASwL6Var269WifrU7g1NRlDf-jxKKs8LzMo8dUkxI,7237
@@ -99,7 +99,7 @@ coretrace_python/findings/__init__.py,sha256=gnJI-uB2CkgZ8I5aFYP_N5yZb7FGNIy7Lir
99
99
  coretrace_python/findings/baseline.py,sha256=fbGQuULRsCjhxSNdwuLfwsvql9ZUfF3jqCXrRmIgOoQ,3468
100
100
  coretrace_python/findings/coverage.py,sha256=U8URLEz6EN4B_TTQlLFCOtEmUbINt7a0Bexb-6OUFlI,1203
101
101
  coretrace_python/findings/model.py,sha256=zRK1FDuLgiVY4ch6H_AN9rDxC1orNW4wc8YN3cfVFfc,1424
102
- coretrace_python/findings/refutation.py,sha256=MmFANBozz0sodIhGUhrgr7BCEwhw5s80kRPRMDRDIyU,20707
102
+ coretrace_python/findings/refutation.py,sha256=UeYCz70LwyL8AMimhs3ys9cH0j1_YbdHVu7y3VvmmUg,22026
103
103
  coretrace_python/findings/suppressions.py,sha256=kN4q0CA1tzeAubeYe4rUv_aRXda4PAsMZY0r8M5cuQI,2633
104
104
  coretrace_python/frontend/__init__.py,sha256=ULI2tf_IRTMboaKM6uzTkGGh4Agy3cFjtw2H1IPrz4g,627
105
105
  coretrace_python/frontend/ast_adapter.py,sha256=VL2CgbQqDGSr2Wh-z0EpR0gBB7T29s0xHvfT-BPx08c,21833
@@ -107,10 +107,10 @@ coretrace_python/frontend/parser.py,sha256=6-CG7rWxUYeOGeDP206SJWy4BMQUP9Mr6Jf-t
107
107
  coretrace_python/hir/__init__.py,sha256=xMpvj-RSp6bd78a_hhkqbe99-snjERkH-_Lv5o9ASbw,180
108
108
  coretrace_python/hir/nodes.py,sha256=4BI0f3UhKbnz9ZIxMoKXM7ujav0BmRpD0OXaoblRxIk,12364
109
109
  coretrace_python/hir/visitors.py,sha256=JFnX09hK6trLyRcWgZ5ehOG2ywX3VvkqQ1h82nahYNg,885
110
- coretrace_python/interprocedural/__init__.py,sha256=SXtIGE3DD4fADIXLDuEvyT_3oIsh7Vio3BL4U6oUm28,1162
110
+ coretrace_python/interprocedural/__init__.py,sha256=rBASF3aAvl5CFAmIISu2gID9mqY_iyi-4Hx0GzDVZsg,1300
111
111
  coretrace_python/interprocedural/callgraph.py,sha256=E6enTawqcl4-TObdjvoFe6l1zEedvsWbnjEBD6omzy8,19827
112
112
  coretrace_python/interprocedural/modulegraph.py,sha256=4sB7OKjBYfxtm7aMtHm2jRlqFVUNUsdnLhnv9UZ1BWA,8846
113
- coretrace_python/interprocedural/summaries.py,sha256=9-WGS-X37KJY1ynoXXA5HNfYFLeCyiKVMAIjeWgaVLI,21333
113
+ coretrace_python/interprocedural/summaries.py,sha256=Z_KT3ggH7fgPz1mJL6TBz6V5STka1koIuUBAuYMti7o,26113
114
114
  coretrace_python/ir/__init__.py,sha256=ShBPYYqoQFePP-mgBjGezQ2acel0-ReOHF9vCC-G_4Q,232
115
115
  coretrace_python/ir/defuse.py,sha256=CZEPnlgntRp4MqwR7kLldd2ZlIlBnr-V9VJAxXxwm0c,2616
116
116
  coretrace_python/ir/lowering.py,sha256=V7r_4tEOi801NBOsPdG6pwrRHZIeVTahygYENneLQ4U,31428
@@ -118,7 +118,7 @@ coretrace_python/ir/model.py,sha256=i7LvHEnl5pmWYUcSui17ZGS1rdPVavMpnjt7Tog7P4c,
118
118
  coretrace_python/ir/printer.py,sha256=otBIhZUvB_UfB68gid_FbPcSjBUSmxH3v7vBMZZQVkg,9125
119
119
  coretrace_python/ir/ssa.py,sha256=cIYbcv90PI6AOeMrw2ZWp88fy7E2ZO4P3tqjtPHra7g,11116
120
120
  coretrace_python/plugins/__init__.py,sha256=xRKqkvkvQMmfQACHHoBioYhIyyK4HoMAVogy6EZG8tg,1398
121
- coretrace_python/plugins/api.py,sha256=NjeDRRwy2K6esSEkg1Ofmv09mqa0El6MlTmRDCWiOmQ,8182
121
+ coretrace_python/plugins/api.py,sha256=FcIe2c4sFB-PAPtYcJkidvdZJkr_xFKT8SvlBP5l38o,8603
122
122
  coretrace_python/plugins/detectors.py,sha256=ej5Z_MS0CBqrR4b9xycjTVUIo5arpArpdbr7nL732iI,5700
123
123
  coretrace_python/plugins/loader.py,sha256=Use-lHTL4-IJYQBZmODC89zhJbYKGvDSLZHWjZa5NaI,3885
124
124
  coretrace_python/plugins/manifest.py,sha256=F24pgQSwApRUL6ZihRSLv9wL_M03A_pZjrj9Zi4Ku3Q,3438
@@ -138,13 +138,13 @@ coretrace_python/source/__init__.py,sha256=0Y8fvtbkETnNCJR9Zq7RS5TSgF9c22pz3SSk1
138
138
  coretrace_python/source/manager.py,sha256=JRmMOcWfS1nocbVrdcvfQTqEfIkmnb5JP857TpGywN4,2736
139
139
  coretrace_python/source/model.py,sha256=FZnN5mlir3tzbIpWoxnn6YtRi9UcJ_y0sZM60tKr8U4,1727
140
140
  coretrace_python/taint/__init__.py,sha256=kfqCEXE5wKFLwz2XrT42_yEv9pW4vPfgg5yYBVVSr2I,1371
141
- coretrace_python/taint/engine.py,sha256=uB0Mga1xeKF6LVHx601pvojb0NLAPG0DqF8fy3dM8oU,40222
142
- coretrace_python/taint/models.py,sha256=i3_Sfwdqf_BGXV47jylfF3vuX6nYJcyAPiwC3xerh6g,14592
141
+ coretrace_python/taint/engine.py,sha256=_uVUc2m29aljN_YR2iCQtEdvZFc-LJ5CDWiZF1Tz3WU,40524
142
+ coretrace_python/taint/models.py,sha256=B84caPxmQvTdB9H_80nZOqcFlpNd9ofpKI2YL5Uc0UA,15269
143
143
  coretrace_python/taint/routes.py,sha256=UAJKXcw_sM9shf58FfDroxVO9bNSm4qrQdZknwUB0lo,6110
144
144
  coretrace_python/taint/templates.py,sha256=8Ojb43VXuWxOVifeXRpi3_2mjZfnvIC7GQ-AW80GUYk,11148
145
- coretrace_python_analyzer-0.7.0.dist-info/METADATA,sha256=lak3Dd-Dpbv_TEo-PAhnBg5r6iwrJmsDM5aa6wogg1w,3216
146
- coretrace_python_analyzer-0.7.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
147
- coretrace_python_analyzer-0.7.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
148
- coretrace_python_analyzer-0.7.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
149
- coretrace_python_analyzer-0.7.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
150
- coretrace_python_analyzer-0.7.0.dist-info/RECORD,,
145
+ coretrace_python_analyzer-0.9.0.dist-info/METADATA,sha256=Yn4OctM3x0MgjM30b8fPuLrCeFRLIBCe4RZROB1eGR0,3216
146
+ coretrace_python_analyzer-0.9.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
147
+ coretrace_python_analyzer-0.9.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
148
+ coretrace_python_analyzer-0.9.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
149
+ coretrace_python_analyzer-0.9.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
150
+ coretrace_python_analyzer-0.9.0.dist-info/RECORD,,