coretrace-python-analyzer 0.15.0__py3-none-any.whl → 0.16.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.15.0"
3
+ __version__ = "0.16.0"
4
4
 
@@ -1,5 +1,6 @@
1
1
  """Calls, anywhere in the project, to an API affected by a vulnerable requirement, and
2
- reads of an affected attribute whose getter runs the vulnerable code."""
2
+ reads of an affected attribute whose getter runs the vulnerable code. A filter a project
3
+ template applies is a call to the function behind it, at the template's line."""
3
4
 
4
5
  from __future__ import annotations
5
6
 
@@ -56,6 +57,10 @@ class ReachableVulnerabilityPlugin(ProjectPlugin):
56
57
  reported.add((advisory, symbol))
57
58
  check = check_conditions(entry, None)
58
59
  findings.append(_reached(advisory, symbol, read.location, function, check))
60
+ for call in ctx.templates.calls:
61
+ for advisory in affected.get(call.symbol, ()):
62
+ check = check_conditions(advisory.entry_point(call.symbol), None)
63
+ findings.append(_reached(advisory, call.symbol, call.span, None, check))
59
64
  return findings
60
65
 
61
66
 
@@ -67,7 +72,9 @@ def _read_through(symbol: SymbolId) -> Iterator[SymbolId]:
67
72
  yield SymbolId(".".join(components[:end]))
68
73
 
69
74
 
70
- def _reached(advisory: Advisory, symbol: SymbolId, span: SourceSpan, function: str, check: ConditionCheck) -> Finding:
75
+ def _reached(
76
+ advisory: Advisory, symbol: SymbolId, span: SourceSpan, function: str | None, check: ConditionCheck
77
+ ) -> Finding:
71
78
  return Finding(
72
79
  rule_id="reachable-vulnerability",
73
80
  message=(
@@ -139,7 +139,10 @@ class DjangoModels(ModelPlugin):
139
139
  Sink(_sym("django.http.HttpResponseRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
140
140
  Sink(_sym("django.http.HttpResponsePermanentRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
141
141
  Sanitizer(_sym("django.utils.html.escape"), TaintKind.HTML),
142
- TemplateRender(_sym("django.template.loader.render_to_string")),
142
+ TemplateRender(_sym("django.template.loader.render_to_string"), request=2),
143
+ TemplateRender(_sym("django.shortcuts.render"), 1, context=2, request=0),
144
+ TemplateRender(_sym("django.template.response.TemplateResponse"), 1, "template", 2, 0),
145
+ TemplateRender(_sym("django.template.response.SimpleTemplateResponse"), 0, "template"),
143
146
  Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML),
144
147
  # The masked CSRF secret: ASCII letters and digits only, a malformed cookie is
145
148
  # replaced before it is used.
@@ -7,7 +7,8 @@ subcomponent of the project:
7
7
  exploitable finding, whatever the policy or a suppression did with it;
8
8
  - ``not_affected``, as ``vulnerable_code_not_in_execute_path``, only when the advisory is
9
9
  curated (it names entry points), no code of the project reaches them, every file and
10
- function was analysed, and a lock file shows that no other package requires the
10
+ function was analysed, every template the project names was read when a template
11
+ filter can reach them, and a lock file shows that no other package requires the
11
12
  vulnerable one, since the code of installed packages is not analysed;
12
13
  - ``under_investigation`` otherwise, with the reason in its notes.
13
14
  """
@@ -25,6 +26,7 @@ from coretrace_python.dependency.graph import Advisory, DependencyGraph, Require
25
26
  from coretrace_python.dependency.sbom import purl
26
27
  from coretrace_python.findings import Component, Finding
27
28
  from coretrace_python.findings.coverage import Coverage
29
+ from coretrace_python.taint import FILTER_FUNCTIONS
28
30
 
29
31
  CONTEXT = "https://openvex.dev/ns/v0.2.0"
30
32
  # OpenVEX's shared namespace for documents without an IRI of their own, and the author
@@ -33,6 +35,8 @@ NAMESPACE = "https://openvex.dev/docs/public/vex-"
33
35
  AUTHOR = "Unknown Author"
34
36
  # Evidence levels that put the vulnerable code in the project's execution path, lowest first.
35
37
  REACHED = ("reachable", "exploitable")
38
+ # What a template calls: a template the engine cannot read may reach these.
39
+ _TEMPLATE_CALLS = frozenset(FILTER_FUNCTIONS.values())
36
40
 
37
41
 
38
42
  def render_vex(
@@ -140,6 +144,11 @@ def _status(
140
144
  f"No analysed code reaches {entries}, but {', '.join(partial)} could not be fully "
141
145
  f"analysed.{ruled}"
142
146
  )
147
+ if coverage.unread_templates and _TEMPLATE_CALLS.intersection(advisory.reachable_symbols):
148
+ return _investigating(
149
+ f"No analysed code reaches {entries}, but a template can reach them, and the engine could "
150
+ f"not read every template the project names: {'; '.join(coverage.unread_templates)}.{ruled}"
151
+ )
143
152
  required_by = dependencies.required_by(package)
144
153
  if required_by is None:
145
154
  return _investigating(
@@ -83,6 +83,8 @@ from coretrace_python.interprocedural import (
83
83
  SummaryAnalysis,
84
84
  SummaryIndex,
85
85
  SymbolRead,
86
+ TemplateCalls,
87
+ TemplateCallsAnalysis,
86
88
  build_module_graph,
87
89
  discover_sources,
88
90
  project_symbol,
@@ -124,7 +126,10 @@ from coretrace_python.taint import (
124
126
  TaintAnalysis,
125
127
  TaintKind,
126
128
  escaped_templates,
129
+ project_templates,
127
130
  registered_routes,
131
+ request_processor,
132
+ unread_renders,
128
133
  )
129
134
  from coretrace_python.taint.urls import flow_url
130
135
 
@@ -158,6 +163,7 @@ ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
158
163
  RegisteredRoutes,
159
164
  EscapedTemplates,
160
165
  ClearingAnalysis,
166
+ TemplateCallsAnalysis,
161
167
  )
162
168
 
163
169
 
@@ -198,6 +204,7 @@ class ResultsEvicted(TransformationPass):
198
204
  RegisteredRoutes,
199
205
  EscapedTemplates,
200
206
  ClearingAnalysis,
207
+ TemplateCallsAnalysis,
201
208
  }
202
209
  )
203
210
 
@@ -431,13 +438,20 @@ def analyze_project(
431
438
  for symbol, registered in _routes_of(analysable[name]).items():
432
439
  routes.setdefault(symbol, registered)
433
440
  escaped = escaped_templates(root)
441
+ templates = project_templates(root)
434
442
  clearing = models.clearing(escaped)
443
+ template_calls = models.template_calls(
444
+ templates.filters, request_processor(modules[name] for name in sorted(analysable))
445
+ )
435
446
  for manager in analysable.values():
436
447
  manager.provide(RegisteredRoutes, routes)
437
448
  manager.provide(EscapedTemplates, escaped)
438
449
  manager.provide(ClearingAnalysis, clearing)
450
+ manager.provide(TemplateCallsAnalysis, template_calls)
439
451
 
440
- configuration = _configuration_key(components, plugins, models, advisories, dependencies, routes, escaped)
452
+ configuration = _configuration_key(
453
+ components, plugins, models, advisories, dependencies, routes, escaped, template_calls
454
+ )
441
455
  keys = module_keys(
442
456
  graph,
443
457
  {name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
@@ -479,6 +493,7 @@ def analyze_project(
479
493
  advisory_paths,
480
494
  _encode_routes(routes),
481
495
  tuple(sorted(escaped)),
496
+ template_calls,
482
497
  ),
483
498
  )
484
499
  for component in pending
@@ -516,7 +531,9 @@ def analyze_project(
516
531
  frozenset(),
517
532
  reads={f: tuple(r) for f, r in reads.items()},
518
533
  )
519
- context = ProjectContext(graph, dependencies, advisories, analysable, call_graphs, policy, root, functions)
534
+ context = ProjectContext(
535
+ graph, dependencies, advisories, analysable, call_graphs, policy, root, functions, templates
536
+ )
520
537
  for plugin in all_plugins:
521
538
  if isinstance(plugin, ProjectPlugin):
522
539
  findings.extend(plugin.analyze_project(context))
@@ -526,6 +543,9 @@ def analyze_project(
526
543
  findings = [_sourced(finding, origins) for finding in findings]
527
544
  accepted = tuple(f for f in findings if policy.accepts(f))
528
545
  kept, suppressed = partition(apply_policy(policy, findings), _text_of(sources))
546
+ unread = list(templates.unread)
547
+ for name in sorted(call_graphs):
548
+ unread.extend(unread_renders(name, call_graphs[name], models, templates.names))
529
549
  return ProjectAnalysis(
530
550
  graph,
531
551
  index,
@@ -534,7 +554,7 @@ def analyze_project(
534
554
  MappingProxyType(keys),
535
555
  reused,
536
556
  advisories,
537
- Coverage(tuple(sorted(coverage, key=lambda c: c.path))),
557
+ Coverage(tuple(sorted(coverage, key=lambda c: c.path)), tuple(unread)),
538
558
  suppressed,
539
559
  accepted,
540
560
  components,
@@ -686,6 +706,7 @@ class _Batch:
686
706
  advisory_paths: tuple[Path, ...] = ()
687
707
  routes: tuple[tuple[str, str, str, int], ...] = ()
688
708
  escaped: tuple[str, ...] = ()
709
+ template_calls: TemplateCalls = field(default_factory=TemplateCalls)
689
710
 
690
711
 
691
712
  def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
@@ -713,6 +734,7 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
713
734
  manager.provide(RegisteredRoutes, routes)
714
735
  manager.provide(EscapedTemplates, escaped)
715
736
  manager.provide(ClearingAnalysis, models.clearing(escaped))
737
+ manager.provide(TemplateCallsAnalysis, batch.template_calls)
716
738
  module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
717
739
  results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
718
740
  return {name: encode(entry) for name, entry in results.items()}
@@ -741,6 +763,7 @@ def _configuration_key(
741
763
  dependencies: DependencyGraph,
742
764
  routes: Routes | None = None,
743
765
  escaped: frozenset[str] = frozenset(),
766
+ template_calls: TemplateCalls | None = None,
744
767
  ) -> str:
745
768
  """Everything a module's results depend on besides the project sources (§11)."""
746
769
 
@@ -758,6 +781,7 @@ def _configuration_key(
758
781
  repr(dependencies.errors),
759
782
  repr(_encode_routes(routes or {})),
760
783
  repr(sorted(escaped)),
784
+ repr(template_calls),
761
785
  )
762
786
 
763
787
 
@@ -2,7 +2,9 @@
2
2
 
3
3
  "No findings" only means something when the reader knows what was analysed. Each file
4
4
  is ``analysed``, a ``syntax-error`` (the frontend rejected it) or ``unreadable`` (it could
5
- not be decoded); analysed files count their functions and how many lowered.
5
+ not be decoded); analysed files count their functions and how many lowered. The
6
+ templates the project names but the run could not read are listed, each where the
7
+ project names it: they may call any template filter.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -21,6 +23,7 @@ class FileCoverage:
21
23
  @dataclass(frozen=True)
22
24
  class Coverage:
23
25
  details: tuple[FileCoverage, ...] = ()
26
+ unread_templates: tuple[str, ...] = ()
24
27
 
25
28
  @property
26
29
  def files(self) -> int:
@@ -20,6 +20,7 @@ from coretrace_python.interprocedural.modulegraph import (
20
20
  project_symbol,
21
21
  )
22
22
  from coretrace_python.interprocedural.summaries import (
23
+ FILTER_ARGUMENTS,
23
24
  Cleared,
24
25
  Clearing,
25
26
  ClearingAnalysis,
@@ -31,10 +32,14 @@ from coretrace_python.interprocedural.summaries import (
31
32
  SummaryAnalysis,
32
33
  SummaryIndex,
33
34
  SummaryTable,
35
+ TemplateCalls,
36
+ TemplateCallsAnalysis,
37
+ TemplateFilter,
34
38
  cleared_by,
35
39
  )
36
40
 
37
41
  __all__ = [
42
+ "FILTER_ARGUMENTS",
38
43
  "Arguments",
39
44
  "CallGraph",
40
45
  "CallGraphAnalysis",
@@ -56,6 +61,9 @@ __all__ = [
56
61
  "SummaryTable",
57
62
  "SymbolRead",
58
63
  "Target",
64
+ "TemplateCalls",
65
+ "TemplateCallsAnalysis",
66
+ "TemplateFilter",
59
67
  "UnknownTarget",
60
68
  "build_module_graph",
61
69
  "cleared_by",
@@ -7,14 +7,17 @@ over the call graph, so recursion converges to the least solution. Summaries car
7
7
  security knowledge of their own: the taint engine decides which external symbols
8
8
  matter. They only keep, per parameter, the bits a ``Clearing`` the engine provides says
9
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.
10
+ back without the kinds a sanitizer inside the function removed. A call rendering a
11
+ template makes the filter calls ``TemplateCalls`` says the template makes, with the
12
+ values of the context entries it reads.
11
13
  """
12
14
 
13
15
  from __future__ import annotations
14
16
 
15
17
  import dataclasses
16
- from collections.abc import Iterable, Mapping
18
+ from collections.abc import Iterable, Iterator, Mapping
17
19
  from dataclasses import dataclass
20
+ from functools import cached_property
18
21
  from types import MappingProxyType
19
22
  from typing import ClassVar, TypeVar
20
23
 
@@ -38,6 +41,7 @@ from coretrace_python.interprocedural.callgraph import (
38
41
  UnknownTarget,
39
42
  _is_staticmethod,
40
43
  )
44
+ from coretrace_python.ir.defuse import DefUse, def_use
41
45
  from coretrace_python.ir.model import (
42
46
  BasicBlock,
43
47
  Branch,
@@ -145,6 +149,131 @@ class Clearing:
145
149
  return bits
146
150
 
147
151
 
152
+ @dataclass(frozen=True)
153
+ class TemplateFilter:
154
+ """A filter call rendering a template makes: the function behind the filter, where
155
+ it is applied, in the template named ``template``, and the variables of the render,
156
+ each with its first attribute (``bio``, ``request.GET``), whose values reach its value
157
+ and its argument. They are sorted: the calls are part of the cache key."""
158
+
159
+ symbol: SymbolId
160
+ location: SourceSpan
161
+ template: str
162
+ value: tuple[str, ...] = ()
163
+ argument: tuple[str, ...] = ()
164
+
165
+
166
+ # What the analysis knows of the arguments of a filter call: nothing, so no condition on
167
+ # them is decided.
168
+ FILTER_ARGUMENTS = Arguments(unpacked=True)
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class Rendering:
173
+ """What a render call gives the template: the filter calls it makes, the context
174
+ ``entries`` by key, and the ``request`` the request context processor adds, if it
175
+ certainly does. An entry of that name shadows the request."""
176
+
177
+ filters: tuple[TemplateFilter, ...]
178
+ entries: Mapping[str, Value]
179
+ request: Value | None = None
180
+
181
+ def values(self, names: tuple[str, ...]) -> Iterator[tuple[Value, str | None]]:
182
+ """The values reaching a filter through ``names``, each a variable with its first
183
+ attribute: a context entry, or the request with the attribute read from it."""
184
+
185
+ for name in names:
186
+ variable, _, attribute = name.partition(".")
187
+ if variable in self.entries:
188
+ yield self.entries[variable], None
189
+ elif variable == REQUEST and self.request is not None and attribute:
190
+ yield self.request, attribute
191
+
192
+
193
+ # The variable the request context processor adds.
194
+ REQUEST = "request"
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class TemplateCalls:
199
+ """The filter calls a render makes, provided by the engine from the project's
200
+ templates: ``renders`` gives, for each render symbol, the position and keyword of
201
+ the template's name, the position of its ``context`` and that of its ``request``
202
+ (None when it takes none); ``filters`` the calls rendering each template makes, by
203
+ its name the way call sites record a constant; ``request`` says the request context
204
+ processor certainly runs for every template."""
205
+
206
+ renders: Mapping[SymbolId, tuple[int, str, int, int | None]] = dataclasses.field(default_factory=dict)
207
+ filters: Mapping[str, tuple[TemplateFilter, ...]] = dataclasses.field(default_factory=dict)
208
+ request: bool = False
209
+
210
+ def rendering(
211
+ self, symbol: SymbolId, call: Call, arguments: Arguments, defs: Mapping[Value, Instruction], uses: DefUse
212
+ ) -> Rendering | None:
213
+ """What ``call`` gives the template it names, when it renders one whose filter
214
+ calls are known. None unless the context is certain: absent, or a dict literal
215
+ with constant keys that ``call`` alone uses; any other context may hold other
216
+ values, or a ``request`` entry, by the time the template reads it."""
217
+
218
+ render = self.renders.get(symbol)
219
+ if render is None:
220
+ return None
221
+ position, keyword, context, request = render
222
+ named = arguments.given(keyword, position)
223
+ filters = self.filters.get(named[1] or "", ()) if named is not None and named[0] else ()
224
+ passed = _passed(call, context, "context") if filters else None
225
+ entries = _literal_entries(passed[1], defs, uses) if passed is not None and passed[0] else None
226
+ if entries is None:
227
+ return None
228
+ given = _passed(call, request, REQUEST) if self.request and request is not None else None
229
+ return Rendering(filters, entries, given[1] if given is not None and given[0] else None)
230
+
231
+
232
+ def _passed(call: Call, position: int, keyword: str) -> tuple[bool, Value | None]:
233
+ """Whether what ``call`` passes at ``position`` or as ``keyword`` is certain, and
234
+ that value: None when the call passes none."""
235
+
236
+ if call.starred or any(name is None for name, _ in call.keywords):
237
+ return False, None
238
+ if position < len(call.arguments):
239
+ return True, call.arguments[position]
240
+ return True, next((value for name, value in call.keywords if name == keyword), None)
241
+
242
+
243
+ def _is_none(value: Value | None, defs: Mapping[Value, Instruction]) -> bool:
244
+ constant = defs.get(value) if value is not None else None
245
+ return value is None or (isinstance(constant, Constant) and constant.value is None)
246
+
247
+
248
+ def _literal_entries(value: Value | None, defs: Mapping[Value, Instruction], uses: DefUse) -> Mapping[str, Value] | None:
249
+ """The entries of a context, by key: none for an absent context, None unless it is a
250
+ dict literal with constant keys that nothing else uses."""
251
+
252
+ if _is_none(value, defs):
253
+ return {}
254
+ built = defs.get(value) if value is not None else None
255
+ if value is None or not isinstance(built, BuildDict) or built.unpacked or len(uses.uses(value)) != 1:
256
+ return None
257
+ entries: dict[str, Value] = {}
258
+ for key, item in built.items:
259
+ constant = defs.get(key)
260
+ if not isinstance(constant, Constant) or not isinstance(constant.value, str):
261
+ return None
262
+ entries[constant.value] = item
263
+ return entries
264
+
265
+
266
+ class TemplateCallsAnalysis(Analysis[TemplateCalls]):
267
+ """The filter calls renders make, provided by the engine for a project; none on its
268
+ own."""
269
+
270
+ name: ClassVar[str] = "interprocedural.template_calls"
271
+
272
+ @classmethod
273
+ def compute(cls, ctx: AnalysisContext) -> TemplateCalls:
274
+ return TemplateCalls()
275
+
276
+
148
277
  class ClearingAnalysis(Analysis[Clearing]):
149
278
  """What calls clear, provided by the engine from the security models; nothing on its
150
279
  own, so a summary then keeps every kind."""
@@ -278,6 +407,7 @@ class _DependenceProblem(DataflowProblem[State]):
278
407
  project: Mapping[SymbolId, FunctionSummary] | None = None,
279
408
  heap: HeapFacts | None = None,
280
409
  clearing: Clearing | None = None,
410
+ templates: TemplateCalls | None = None,
281
411
  ) -> None:
282
412
  self.name = name
283
413
  self.function = function
@@ -286,6 +416,7 @@ class _DependenceProblem(DataflowProblem[State]):
286
416
  self.project = project or {}
287
417
  self.heap = heap or HeapFacts({})
288
418
  self.clearing = clearing or Clearing()
419
+ self.templates = templates or TemplateCalls()
289
420
  self.blocks = {block.id: block for block in function.blocks}
290
421
  self.defs: dict[Value, Instruction] = {
291
422
  i.result: i for block in function.blocks for i in block.instructions if i.result
@@ -428,6 +559,7 @@ class _DependenceProblem(DataflowProblem[State]):
428
559
  return self.known(project, arguments, keywords, everything, call, state, bound)
429
560
  given = self.graph.arguments_at(self.name, call.location)
430
561
  self.record(target.symbol, arguments, named, call.location, None, given)
562
+ self.record_filters(target.symbol, call, given, state)
431
563
  cleared = everything.clearing(self.clearing.clears(target.symbol, given))
432
564
  return cleared | Dep(externals=frozenset({target.symbol}))
433
565
  if isinstance(target, KnownFunction):
@@ -521,6 +653,31 @@ class _DependenceProblem(DataflowProblem[State]):
521
653
  self.store(state, values[mutation.parameter], mutation.field, deps)
522
654
  return mapped(callee.return_dependencies, callee.return_cleared) | Dep(externals=callee.return_externals)
523
655
 
656
+ @cached_property
657
+ def uses(self) -> DefUse:
658
+ return def_use(self.function)
659
+
660
+ def record_filters(self, symbol: SymbolId, call: Call, given: Arguments, state: Mapping[Key, Dep]) -> None:
661
+ """The filter calls a render makes, each at the filter in its template, with what
662
+ the context entries reaching its value and its argument depend on. The request the
663
+ request context processor adds is not followed through a project function."""
664
+
665
+ rendering = self.templates.rendering(symbol, call, given, self.defs, self.uses)
666
+ if rendering is None:
667
+ return
668
+ for template_filter in rendering.filters:
669
+ passed: list[Dep] = []
670
+ for names in (template_filter.value, template_filter.argument):
671
+ deps = EMPTY
672
+ for value, attribute in rendering.values(names):
673
+ # Only the kinds of a request object say which of its attributes the
674
+ # user controls, and a dependency carries no kinds.
675
+ if attribute is None:
676
+ deps |= self.deep(value, state)
677
+ passed.append(deps)
678
+ if any(deps.parameters for deps in passed):
679
+ self.record(template_filter.symbol, tuple(passed), (), template_filter.location, None, FILTER_ARGUMENTS)
680
+
524
681
  def record(
525
682
  self,
526
683
  symbol: SymbolId,
@@ -570,8 +727,9 @@ def summarize(
570
727
  heap: HeapFacts | None = None,
571
728
  static: bool = False,
572
729
  clearing: Clearing | None = None,
730
+ templates: TemplateCalls | None = None,
573
731
  ) -> FunctionSummary:
574
- problem = _DependenceProblem(name, function, graph, table, project, heap, clearing)
732
+ problem = _DependenceProblem(name, function, graph, table, project, heap, clearing, templates)
575
733
  solution = solve(problem, cfg)
576
734
  problem.external = {}
577
735
  problem.returns = EMPTY
@@ -610,7 +768,15 @@ def summarize(
610
768
  class SummaryAnalysis(Analysis[SummaryTable]):
611
769
  name: ClassVar[str] = "interprocedural.summaries"
612
770
  requires: ClassVar[frozenset[AnyAnalysis]] = frozenset(
613
- {CallGraphAnalysis, SSAAnalysis, CFGAnalysis, ProjectSummaries, HeapAnalysis, ClearingAnalysis}
771
+ {
772
+ CallGraphAnalysis,
773
+ SSAAnalysis,
774
+ CFGAnalysis,
775
+ ProjectSummaries,
776
+ HeapAnalysis,
777
+ ClearingAnalysis,
778
+ TemplateCallsAnalysis,
779
+ }
614
780
  )
615
781
 
616
782
  @classmethod
@@ -618,6 +784,7 @@ class SummaryAnalysis(Analysis[SummaryTable]):
618
784
  graph = ctx.get(CallGraphAnalysis)
619
785
  project = ctx.get(ProjectSummaries).summaries
620
786
  clearing = ctx.get(ClearingAnalysis)
787
+ templates = ctx.get(TemplateCallsAnalysis)
621
788
  table: dict[str, FunctionSummary] = {}
622
789
  supported: dict[str, tuple[FunctionIR, CFG, HeapFacts]] = {}
623
790
  for name, function in graph.definitions.items():
@@ -635,9 +802,8 @@ class SummaryAnalysis(Analysis[SummaryTable]):
635
802
  while changed:
636
803
  changed = False
637
804
  for name, (ssa, cfg, heap) in supported.items():
638
- updated = summarize(
639
- name, ssa, cfg, graph, table, project, heap, _is_staticmethod(graph.definitions[name]), clearing
640
- )
805
+ static = _is_staticmethod(graph.definitions[name])
806
+ updated = summarize(name, ssa, cfg, graph, table, project, heap, static, clearing, templates)
641
807
  if updated != table[name]:
642
808
  table[name] = updated
643
809
  changed = True
@@ -33,7 +33,7 @@ from coretrace_python.interprocedural import (
33
33
  )
34
34
  from coretrace_python.ir.lowering import analyzable_functions
35
35
  from coretrace_python.semantic.imports import ImportAnalysis, ImportTable
36
- from coretrace_python.taint import EntryPointAnalysis, Model
36
+ from coretrace_python.taint import EntryPointAnalysis, Model, ProjectTemplates
37
37
 
38
38
  PLUGIN_API_VERSION = 1
39
39
 
@@ -69,9 +69,10 @@ class ModelPlugin(Plugin):
69
69
 
70
70
  class ProjectContext:
71
71
  """What a project-scoped plugin sees: the module graph, the dependency graph, the
72
- advisories every plugin and advisory file contributed, the dependency policy, and
73
- each module's imports, call graph and functions. The engine passes the call graphs
74
- and functions of modules it served from its cache."""
72
+ advisories every plugin and advisory file contributed, the dependency policy, each
73
+ module's imports, call graph and functions, and the project's templates with the
74
+ filters they call. The engine passes the call graphs and functions of modules it
75
+ served from its cache."""
75
76
 
76
77
  def __init__(
77
78
  self,
@@ -83,8 +84,10 @@ class ProjectContext:
83
84
  policy: Policy | None = None,
84
85
  root: Path | None = None,
85
86
  functions: Mapping[str, tuple[ModuleFunction, ...]] | None = None,
87
+ templates: ProjectTemplates | None = None,
86
88
  ) -> None:
87
89
  self.root = root
90
+ self.templates = templates or ProjectTemplates()
88
91
  self.graph = graph
89
92
  self.dependencies = dependencies
90
93
  self.advisories = advisories
@@ -32,19 +32,31 @@ from coretrace_python.taint.models import (
32
32
  Validator,
33
33
  )
34
34
  from coretrace_python.taint.routes import RegisteredRoutes, Routes, registered_routes
35
- from coretrace_python.taint.templates import EscapedTemplates, escaped_templates
35
+ from coretrace_python.taint.templates import (
36
+ FILTER_FUNCTIONS,
37
+ EscapedTemplates,
38
+ FilterCall,
39
+ ProjectTemplates,
40
+ escaped_templates,
41
+ project_templates,
42
+ request_processor,
43
+ unread_renders,
44
+ )
36
45
 
37
46
  __all__ = [
47
+ "FILTER_FUNCTIONS",
38
48
  "TEXT_KINDS",
39
49
  "AuthorizationGuard",
40
50
  "EntryPoint",
41
51
  "EntryPointAnalysis",
42
52
  "EscapedTemplates",
53
+ "FilterCall",
43
54
  "Members",
44
55
  "Model",
45
56
  "ModelError",
46
57
  "ModelTable",
47
58
  "NamedParameter",
59
+ "ProjectTemplates",
48
60
  "RegisteredRoutes",
49
61
  "RequestObject",
50
62
  "RouteRegistrar",
@@ -65,6 +77,9 @@ __all__ = [
65
77
  "TypedParameter",
66
78
  "Validator",
67
79
  "escaped_templates",
80
+ "project_templates",
68
81
  "propagate_taint",
69
82
  "registered_routes",
83
+ "request_processor",
84
+ "unread_renders",
70
85
  ]