coretrace-python-analyzer 0.6.0__py3-none-any.whl → 0.8.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.6.0"
3
+ __version__ = "0.8.0"
4
4
 
@@ -23,6 +23,7 @@ from coretrace_python.taint import (
23
23
  Source,
24
24
  SuffixSink,
25
25
  TaintKind,
26
+ TemplateRender,
26
27
  TypedParameter,
27
28
  )
28
29
 
@@ -119,7 +120,15 @@ class DjangoModels(ModelPlugin):
119
120
  Sink(_sym("django.http.HttpResponseRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
120
121
  Sink(_sym("django.http.HttpResponsePermanentRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
121
122
  Sanitizer(_sym("django.utils.html.escape"), TaintKind.HTML),
123
+ TemplateRender(_sym("django.template.loader.render_to_string")),
122
124
  Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML),
125
+ # The masked CSRF secret: ASCII letters and digits only, a malformed cookie is
126
+ # replaced before it is used.
127
+ Sanitizer(_sym("django.middleware.csrf.get_token"), TaintKind.ALL),
128
+ # A path of the project's URL configuration, never scheme-relative: attacker data
129
+ # in its arguments or query reaches no other host through a redirect.
130
+ *(Sanitizer(_sym(f"django.urls.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")),
131
+ *(Sanitizer(_sym(f"django.urls.base.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")),
123
132
  *(AuthorizationGuard(_sym(decorator), label) for decorator, label in _AUTHORIZATION_DECORATORS),
124
133
  # ``urlpatterns = [path('login/', views.log_in)]``: the referenced view is an
125
134
  # entry point wherever it is defined; routers register viewsets.
@@ -1,5 +1,8 @@
1
- """Requests and httpx models: every request function is a SSRF sink, and what it returns
2
- is an ``http-response`` source, so data fetched from a remote server is untrusted."""
1
+ """Requests and httpx models: every request function is a SSRF sink for the argument that
2
+ chooses the destination, and what it returns is an ``http-response`` source, so data
3
+ fetched from a remote server is untrusted. The destination is the URL — first, or second
4
+ after the method, or ``url=`` — or the prepared request a ``send`` takes; the body, the
5
+ headers and the query parameters do not choose the host."""
3
6
 
4
7
  from __future__ import annotations
5
8
 
@@ -20,15 +23,18 @@ _CALLERS = (
20
23
  "httpx.AsyncClient",
21
24
  )
22
25
 
23
- _REQUEST_FUNCTIONS = (
24
- *(f"{caller}.{method}" for caller in _CALLERS for method in _METHODS),
25
- "requests.Session.send",
26
- "httpx.stream",
27
- "httpx.Client.stream",
28
- "httpx.AsyncClient.stream",
29
- "httpx.Client.send",
30
- "httpx.AsyncClient.send",
31
- )
26
+ # Where each function takes its destination, by position (the receiver of a method
27
+ # excluded) and by keyword, as its signature declares it.
28
+ _DESTINATIONS = {
29
+ **{f"{caller}.{method}": (0, "url") for caller in _CALLERS for method in _METHODS if method != "request"},
30
+ **{f"{caller}.request": (1, "url") for caller in _CALLERS},
31
+ "httpx.stream": (1, "url"),
32
+ "httpx.Client.stream": (1, "url"),
33
+ "httpx.AsyncClient.stream": (1, "url"),
34
+ "requests.Session.send": (0, "request"),
35
+ "httpx.Client.send": (0, "request"),
36
+ "httpx.AsyncClient.send": (0, "request"),
37
+ }
32
38
 
33
39
 
34
40
  def _sym(path: str) -> SymbolId:
@@ -38,6 +44,9 @@ def _sym(path: str) -> SymbolId:
38
44
  class HttpClientModels(ModelPlugin):
39
45
  name: ClassVar[str] = "http-client-models"
40
46
  models: ClassVar[tuple[Model, ...]] = (
41
- *(Sink(_sym(function), TaintKind.SSRF) for function in _REQUEST_FUNCTIONS),
42
- *(Source(_sym(function), "http-response") for function in _REQUEST_FUNCTIONS),
47
+ *(
48
+ Sink(_sym(function), TaintKind.SSRF, ((TaintKind.SSRF, (position,)),), ((TaintKind.SSRF, (keyword,)),))
49
+ for function, (position, keyword) in _DESTINATIONS.items()
50
+ ),
51
+ *(Source(_sym(function), "http-response") for function in _DESTINATIONS),
43
52
  )
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 = 8
45
+ CACHE_FORMAT = 10
45
46
 
46
47
 
47
48
  @dataclass(frozen=True)
@@ -233,10 +234,12 @@ def _encode_call(call: ExternalCall) -> dict[str, Any]:
233
234
  return {
234
235
  "symbol": str(call.symbol),
235
236
  "arguments": [sorted(deps) for deps in call.argument_dependencies],
236
- "keywords": sorted(call.keyword_dependencies),
237
+ "keywords": [[name, sorted(deps)] for name, deps in call.keyword_dependencies],
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
 
@@ -245,13 +248,23 @@ def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
245
248
  return ExternalCall(
246
249
  SymbolId(_string(data["symbol"])),
247
250
  tuple(_indices(deps) for deps in data["arguments"]),
248
- _indices(data["keywords"]),
251
+ tuple((None if name is None else _string(name), _indices(deps)) for name, deps in data["keywords"]),
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
 
@@ -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,
@@ -109,6 +110,7 @@ from coretrace_python.source import SourceFile, SourceId, SourceManager, SourceS
109
110
  from coretrace_python.taint import (
110
111
  EntryPoint,
111
112
  EntryPointAnalysis,
113
+ EscapedTemplates,
112
114
  ModelTable,
113
115
  RegisteredRoutes,
114
116
  Routes,
@@ -116,6 +118,7 @@ from coretrace_python.taint import (
116
118
  SecurityModelRegistry,
117
119
  TaintAnalysis,
118
120
  TaintKind,
121
+ escaped_templates,
119
122
  registered_routes,
120
123
  )
121
124
 
@@ -146,6 +149,8 @@ ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
146
149
  RefutationAnalysis,
147
150
  DependencyAnalysis,
148
151
  RegisteredRoutes,
152
+ EscapedTemplates,
153
+ ClearingAnalysis,
149
154
  )
150
155
 
151
156
 
@@ -178,7 +183,15 @@ class ResultsEvicted(TransformationPass):
178
183
 
179
184
  name: ClassVar[str] = "project.results-evicted"
180
185
  preserves: ClassVar[frozenset[AnyAnalysis]] = frozenset(
181
- {*SEMANTIC_ANALYSES, SecurityModelAnalysis, ProjectSummaries, DependencyAnalysis, RegisteredRoutes}
186
+ {
187
+ *SEMANTIC_ANALYSES,
188
+ SecurityModelAnalysis,
189
+ ProjectSummaries,
190
+ DependencyAnalysis,
191
+ RegisteredRoutes,
192
+ EscapedTemplates,
193
+ ClearingAnalysis,
194
+ }
182
195
  )
183
196
 
184
197
  @classmethod
@@ -236,7 +249,9 @@ def build_manager(
236
249
  """A manager with every engine analysis registered and the engine inputs provided."""
237
250
 
238
251
  manager = _register_all(module)
239
- manager.provide(SecurityModelAnalysis, (models or SecurityModelRegistry()).freeze())
252
+ table = (models or SecurityModelRegistry()).freeze()
253
+ manager.provide(SecurityModelAnalysis, table)
254
+ manager.provide(ClearingAnalysis, table.clearing())
240
255
  manager.provide(ProjectSummaries, SummaryIndex())
241
256
  manager.provide(RegisteredRoutes, _routes_of(manager))
242
257
  return manager
@@ -266,10 +281,14 @@ def load_plugins(plugin_roots: Sequence[Path], manager: AnalysisManager) -> Plug
266
281
  return registry
267
282
 
268
283
 
269
- 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
+
270
287
  models = SecurityModelRegistry()
271
288
  for plugin in plugins:
272
289
  models.register(*plugin.models, origin=plugin.name)
290
+ if root is not None:
291
+ models.register(*plugin.project_models(root), origin=plugin.name)
273
292
  return models.freeze()
274
293
 
275
294
 
@@ -284,7 +303,9 @@ def analyze_file(source: SourceFile, plugin_roots: Sequence[Path]) -> FileAnalys
284
303
 
285
304
  manager = _register_all(build_hir(source))
286
305
  registry = load_plugins(plugin_roots, manager)
287
- 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())
288
309
  manager.provide(ProjectSummaries, SummaryIndex())
289
310
  manager.provide(RegisteredRoutes, _routes_of(manager))
290
311
  findings, supported = _check_module(manager, tuple(loaded.plugin for loaded in registry))
@@ -375,7 +396,7 @@ def analyze_project(
375
396
  (a for plugin in all_plugins for a in plugin.advisories), file_advisories
376
397
  )
377
398
  affected = affected_symbols(dependencies, advisories)
378
- models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
399
+ models = plugin_models(all_plugins, root).extended(*advisory_sinks(affected))
379
400
  for manager in managers.values():
380
401
  manager.provide(SecurityModelAnalysis, models)
381
402
  manager.provide(DependencyAnalysis, dependencies)
@@ -397,10 +418,14 @@ def analyze_project(
397
418
  for name in sorted(analysable):
398
419
  for symbol, registered in _routes_of(analysable[name]).items():
399
420
  routes.setdefault(symbol, registered)
421
+ escaped = escaped_templates(root)
422
+ clearing = models.clearing(escaped)
400
423
  for manager in analysable.values():
401
424
  manager.provide(RegisteredRoutes, routes)
425
+ manager.provide(EscapedTemplates, escaped)
426
+ manager.provide(ClearingAnalysis, clearing)
402
427
 
403
- configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes)
428
+ configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes, escaped)
404
429
  keys = module_keys(
405
430
  graph,
406
431
  {name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
@@ -441,6 +466,7 @@ def analyze_project(
441
466
  encode_index(_seed(results, graph, component)),
442
467
  advisory_paths,
443
468
  _encode_routes(routes),
469
+ tuple(sorted(escaped)),
444
470
  ),
445
471
  )
446
472
  for component in pending
@@ -588,6 +614,7 @@ class _Batch:
588
614
  seed: Mapping[str, Any]
589
615
  advisory_paths: tuple[Path, ...] = ()
590
616
  routes: tuple[tuple[str, str, str, int], ...] = ()
617
+ escaped: tuple[str, ...] = ()
591
618
 
592
619
 
593
620
  def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
@@ -606,12 +633,15 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
606
633
  (a for plugin in all_plugins for a in plugin.advisories), file_advisories
607
634
  )
608
635
  affected = affected_symbols(dependencies, advisories)
609
- models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
636
+ models = plugin_models(all_plugins, batch.root).extended(*advisory_sinks(affected))
610
637
  routes = _decode_routes(batch.routes)
638
+ escaped = frozenset(batch.escaped)
611
639
  for manager in managers.values():
612
640
  manager.provide(SecurityModelAnalysis, models)
613
641
  manager.provide(DependencyAnalysis, dependencies)
614
642
  manager.provide(RegisteredRoutes, routes)
643
+ manager.provide(EscapedTemplates, escaped)
644
+ manager.provide(ClearingAnalysis, models.clearing(escaped))
615
645
  module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
616
646
  results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
617
647
  return {name: encode(entry) for name, entry in results.items()}
@@ -639,6 +669,7 @@ def _configuration_key(
639
669
  advisories: tuple[Advisory, ...],
640
670
  dependencies: DependencyGraph,
641
671
  routes: Routes | None = None,
672
+ escaped: frozenset[str] = frozenset(),
642
673
  ) -> str:
643
674
  """Everything a module's results depend on besides the project sources (§11)."""
644
675
 
@@ -658,6 +689,7 @@ def _configuration_key(
658
689
  repr(dependencies.requirements),
659
690
  repr(dependencies.errors),
660
691
  repr(_encode_routes(routes or {})),
692
+ repr(sorted(escaped)),
661
693
  )
662
694
 
663
695
 
@@ -8,10 +8,13 @@ and ``not`` combine as expected, a ``Validator`` model names a callable whose tr
8
8
  one of its arguments, a numeric value (``abstract.ranges``) cannot inject, and anything
9
9
  else that mentions the value is a guard that does not prove it. A proof counts for an
10
10
  origin when every dependence path from that origin to the sink argument goes through a
11
- proven value. A flow is refuted when every tainted origin is proven safe or the sink is
12
- unreachable, a hotspot when it sits behind an ``AuthorizationGuard`` (a decorator or a
13
- dominating condition) or when an unproven guard mentions it, and a vulnerability
14
- otherwise.
11
+ proven value. A guard mentions an origin only when it examines what the sink receives:
12
+ reading another attribute or item of an object the flow reads (``request.method`` next
13
+ to ``request.POST``) examines nothing of it, while the same attribute, or a method called
14
+ on the object (``form.is_valid()``), does. A flow is refuted when every tainted origin is
15
+ proven safe or the sink is unreachable, a hotspot when it sits behind an
16
+ ``AuthorizationGuard`` (a decorator or a dominating condition) or when an unproven guard
17
+ mentions it, and a vulnerability otherwise.
15
18
  """
16
19
 
17
20
  from __future__ import annotations
@@ -26,7 +29,12 @@ from coretrace_python.abstract import ConstantPropagation, RangeAnalysis, RangeF
26
29
  from coretrace_python.analysis import AnalysisContext, AnyAnalysis, FunctionAnalysis
27
30
  from coretrace_python.cfg import CFG, BlockId, CFGAnalysis, DominanceAnalysis, DominatorTree
28
31
  from coretrace_python.hir import nodes
29
- from coretrace_python.interprocedural import CallGraphAnalysis
32
+ from coretrace_python.interprocedural import (
33
+ CallGraph,
34
+ CallGraphAnalysis,
35
+ KnownFunction,
36
+ project_symbol,
37
+ )
30
38
  from coretrace_python.ir.model import (
31
39
  BoolOp,
32
40
  Branch,
@@ -37,6 +45,7 @@ from coretrace_python.ir.model import (
37
45
  Constant,
38
46
  FunctionIR,
39
47
  GetAttr,
48
+ GetItem,
40
49
  Instruction,
41
50
  UnaryOp,
42
51
  Value,
@@ -51,6 +60,7 @@ from coretrace_python.taint import (
51
60
  TaintAnalysis,
52
61
  TaintFacts,
53
62
  TaintFlow,
63
+ TaintKind,
54
64
  )
55
65
 
56
66
  VALIDATORS = frozenset(
@@ -210,15 +220,16 @@ class _Judge:
210
220
 
211
221
  def interpret(
212
222
  self, condition: Value, truth: bool | None
213
- ) -> tuple[dict[Value, str], set[Value]]:
214
- """Values the condition proves safe (with the reason), and values it mentions
215
- 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.
216
227
 
217
228
  A recognised check evaluated the wrong way (``isdigit()`` known false) yields
218
229
  nothing: it is neither a proof nor a reassuring guard."""
219
230
 
220
231
  definition = self.defs.get(condition)
221
- proven: dict[Value, str] = {}
232
+ proven: dict[Value, tuple[str, TaintKind]] = {}
222
233
  mentioned: set[Value] = set()
223
234
  if isinstance(definition, UnaryOp) and definition.operator == "not":
224
235
  return self.interpret(definition.operand, None if truth is None else not truth)
@@ -232,27 +243,29 @@ class _Judge:
232
243
  recognised = self.recognise(definition)
233
244
  if recognised is None:
234
245
  return proven, set(self.closure(condition)) | {condition}
235
- tested, reason, when_true = recognised
246
+ tested, reason, when_true, kinds = recognised
236
247
  if truth is None:
237
248
  mentioned = set(self.closure(condition)) | {condition}
238
249
  elif truth == when_true:
239
250
  # The check proves ``tested``; whatever else it reads is merely mentioned.
240
- proven[tested] = reason
251
+ proven[tested] = (reason, kinds)
241
252
  mentioned = set(self.closure(condition)) | {condition}
242
253
  return proven, mentioned
243
254
 
244
- def recognise(self, definition: Instruction | None) -> tuple[Value, str, bool] | None:
245
- """``(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."""
246
259
 
247
260
  if isinstance(definition, Call) and not definition.arguments:
248
261
  callee = self.defs.get(definition.callee)
249
262
  if isinstance(callee, GetAttr) and callee.attribute in VALIDATORS:
250
- return callee.object, f"guarded by {callee.attribute}()", True
263
+ return callee.object, f"guarded by {callee.attribute}()", True, TaintKind.ALL
251
264
  if isinstance(definition, Call):
252
265
  symbol = self.symbols.get(definition.callee)
253
266
  validator = self.models.validator(symbol) if symbol is not None else None
254
267
  if validator is not None and validator.argument < len(definition.arguments):
255
- return definition.arguments[validator.argument], f"validated by {symbol}", True
268
+ return definition.arguments[validator.argument], f"validated by {symbol}", True, validator.kinds
256
269
  if isinstance(definition, Compare):
257
270
  left, right = definition.left, definition.right
258
271
  if definition.operator in ("in", "not_in") and self.is_constant_collection(right):
@@ -260,11 +273,72 @@ class _Judge:
260
273
  left,
261
274
  "allowlisted by a membership check on constants",
262
275
  definition.operator == "in",
276
+ TaintKind.ALL,
263
277
  )
264
278
  if definition.operator in ("eq", "not_eq"):
265
279
  for tested, other in ((left, right), (right, left)):
266
280
  if isinstance(self.defs.get(other), Constant):
267
- return tested, "equals a constant", definition.operator == "eq"
281
+ return tested, "equals a constant", definition.operator == "eq", TaintKind.ALL
282
+ return None
283
+
284
+ def examined(
285
+ self, condition: Value, mentioned: set[Value], argument: Value, chain: frozenset[Value]
286
+ ) -> set[Value]:
287
+ """What the guard examines of the values it mentions: everything its condition is
288
+ computed from, except an object it only reads another attribute or item of than
289
+ the flow reads (``request`` in ``request.method`` when the sink receives
290
+ ``request.POST['cmd']``). A method called on the object (``form.is_valid()``), the
291
+ same attribute or item, or any part of an object the flow takes whole (passes to
292
+ the sink or to a call), examines it."""
293
+
294
+ reads = {self.access(value) for value in chain} - {None}
295
+ whole = {argument} | {
296
+ operand
297
+ for value in chain
298
+ if (definition := self.defs.get(value)) is not None
299
+ for operand in definition.operands()
300
+ if not (isinstance(definition, GetAttr | GetItem) and definition.object == operand)
301
+ }
302
+ found: set[Value] = set()
303
+ called: set[Value] = set()
304
+ pending = [condition]
305
+ while pending:
306
+ value = pending.pop()
307
+ if value in found:
308
+ continue
309
+ found.add(value)
310
+ definition = self.defs.get(value)
311
+ if definition is None:
312
+ continue
313
+ if isinstance(definition, Call):
314
+ called.add(definition.callee)
315
+ access = None if value in called else self.access(value)
316
+ for operand in definition.operands():
317
+ if access is not None and access[0] == operand and operand not in whole and self.aside(access, reads):
318
+ continue
319
+ pending.append(operand)
320
+ return found & mentioned
321
+
322
+ @staticmethod
323
+ def aside(access: tuple[Value, str, object], reads: set[tuple[Value, str, object] | None]) -> bool:
324
+ """Whether reading ``access`` reads beside what the flow reads of the same object;
325
+ an item under a key unknown on either side may be the same one."""
326
+
327
+ owner, kind, name = access
328
+ if kind == "item" and (name is None or (owner, "item", None) in reads):
329
+ return False
330
+ return access not in reads
331
+
332
+ def access(self, value: Value) -> tuple[Value, str, object] | None:
333
+ """``(object, "attribute" or "item", name or constant key)`` for a read of an
334
+ attribute or an item; ``None`` for any other value, and as the key when unknown."""
335
+
336
+ definition = self.defs.get(value)
337
+ if isinstance(definition, GetAttr):
338
+ return definition.object, "attribute", definition.attribute
339
+ if isinstance(definition, GetItem):
340
+ key = self.defs.get(definition.key)
341
+ return definition.object, "item", key.value if isinstance(key, Constant) else None
268
342
  return None
269
343
 
270
344
  def is_constant_collection(self, value: Value) -> bool:
@@ -323,11 +397,13 @@ class _Judge:
323
397
  )
324
398
  for guard in self.guards(sink):
325
399
  found, mentioned = self.interpret(guard.condition, guard.truth)
326
- for value, reason in found.items():
327
- 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:
328
403
  proven.setdefault(value, reason)
404
+ examined = self.examined(guard.condition, mentioned, flow.argument, chain)
329
405
  for origin in origins:
330
- if origin not in mentions and mentioned & (
406
+ if origin not in mentions and examined & (
331
407
  {origin} | {w for w in chain if origin in self.closure(w)}
332
408
  ):
333
409
  mentions[origin] = guard.line
@@ -413,6 +489,7 @@ class RefutationAnalysis(FunctionAnalysis[Verdicts]):
413
489
  constants = ctx.get(ConstantPropagation, function)
414
490
  graph = ctx.get(CallGraphAnalysis)
415
491
  models = ctx.get(SecurityModelAnalysis)
492
+ name = graph.name_of(function)
416
493
  return judge_flows(
417
494
  ssa,
418
495
  ctx.get(CFGAnalysis, function),
@@ -421,7 +498,21 @@ class RefutationAnalysis(FunctionAnalysis[Verdicts]):
421
498
  frozenset(b.id for b in ssa.blocks if constants.reachable(b.id)),
422
499
  ctx.get(RangeAnalysis, function),
423
500
  models,
424
- graph.symbols(graph.name_of(function)),
501
+ _with_project_callees(ssa, graph, name, ctx.module.name),
425
502
  authorization_of(function, models, ctx.get(ScopeAnalysis), ctx.get(SymbolAnalysis)),
426
503
  )
427
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",