coretrace-python-analyzer 0.17.0__py3-none-any.whl → 0.19.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.17.0"
3
+ __version__ = "0.19.0"
4
4
 
coretrace_python/cache.py CHANGED
@@ -42,7 +42,7 @@ from coretrace_python.interprocedural import (
42
42
  from coretrace_python.semantic.symbols import SymbolId
43
43
  from coretrace_python.source import SourceId, SourceSpan
44
44
 
45
- CACHE_FORMAT = 10
45
+ CACHE_FORMAT = 11
46
46
 
47
47
 
48
48
  @dataclass(frozen=True)
@@ -134,7 +134,7 @@ class ProjectCache:
134
134
  def encode(module: CachedModule) -> dict[str, Any]:
135
135
  return {
136
136
  "format": CACHE_FORMAT,
137
- "functions": [[f.name, _encode_span(f.span), f.entry_point] for f in module.functions],
137
+ "functions": [[f.name, _encode_span(f.span), f.entry_point, list(f.aliases)] for f in module.functions],
138
138
  "summaries": {name: _encode_summary(s) for name, s in module.summaries.items()},
139
139
  "sites": [_encode_site(site) for site in module.sites],
140
140
  "findings": [_encode_finding(finding) for finding in module.findings],
@@ -196,8 +196,13 @@ def _decode_span(data: Any) -> SourceSpan:
196
196
 
197
197
 
198
198
  def _decode_function(data: Any) -> ModuleFunction:
199
- name, span, entry_point = data
200
- return ModuleFunction(_string(name), _decode_span(span), None if entry_point is None else _string(entry_point))
199
+ name, span, entry_point, aliases = data
200
+ return ModuleFunction(
201
+ _string(name),
202
+ _decode_span(span),
203
+ None if entry_point is None else _string(entry_point),
204
+ tuple(_string(alias) for alias in aliases),
205
+ )
201
206
 
202
207
 
203
208
  def _decode_read(data: Any) -> SymbolRead:
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import concurrent.futures
10
10
  import hashlib
11
11
  import multiprocessing
12
+ from collections import Counter
12
13
  from collections.abc import Callable, Iterable, Mapping, Sequence
13
14
  from dataclasses import dataclass, field, replace
14
15
  from pathlib import Path
@@ -88,6 +89,7 @@ from coretrace_python.interprocedural import (
88
89
  TemplateCallsAnalysis,
89
90
  build_module_graph,
90
91
  discover_sources,
92
+ name_modules,
91
93
  project_symbol,
92
94
  )
93
95
  from coretrace_python.ir import PYIR_SCHEMA_VERSION
@@ -371,7 +373,11 @@ def analyze_project(
371
373
  strongly connected components of the module graph, imports first; with ``jobs``
372
374
  above one the components of a wave are analysed in that many processes (§29).
373
375
  ``advisory_files`` add to the ``advisories.json`` at ``root``; ``policy_file``
374
- replaces the ``coretrace-policy.toml`` there."""
376
+ replaces the ``coretrace-policy.toml`` there.
377
+
378
+ Every module is known by the name ``name_modules`` gives its file: its import name,
379
+ or its path from the root when another file has that import name. Such files carry
380
+ an ``ambiguous-module`` note and are covered as ``ambiguous``."""
375
381
 
376
382
  if jobs < 1:
377
383
  raise ValueError("jobs must be at least 1")
@@ -398,18 +404,18 @@ def analyze_project(
398
404
  files: dict[str, SourceFile] = {}
399
405
  unreadable: list[tuple[Path, str]] = []
400
406
  coverage: list[FileCoverage] = []
401
- discovered = discover_sources(root, sources, unreadable)
407
+ named = name_modules(root, discover_sources(root, sources, unreadable))
402
408
  for path, reason in unreadable:
403
409
  findings.append(_note("syntax-error", f"{path}: {reason}", sources.add_source(str(path), ""), 1))
404
410
  coverage.append(FileCoverage(str(path), "unreadable", 0, 0))
405
- for source in discovered:
411
+ for name, source in named.items():
406
412
  try:
407
- modules[source.module_name] = build_hir(source)
413
+ modules[name] = build_hir(source)
408
414
  except (ParseError, HIRBuildError) as error:
409
415
  findings.append(_note("syntax-error", str(error), source, 1))
410
416
  coverage.append(FileCoverage(str(source.source_id), "syntax-error", 0, 0))
411
417
  continue
412
- files[source.module_name] = source
418
+ files[name] = source
413
419
 
414
420
  managers = {name: _register_all(module) for name, module in modules.items()}
415
421
  probe = next(iter(managers.values()), None) or _register_all(build_hir(sources.add_source("<empty>", "")))
@@ -439,6 +445,8 @@ def analyze_project(
439
445
  graph = build_module_graph(
440
446
  {name: files[name] for name in analysable}, {name: modules[name] for name in analysable}, imports
441
447
  )
448
+ shared = _shared_names(graph)
449
+ findings.extend(_ambiguity_notes(graph, shared, root))
442
450
  routes: dict[SymbolId, EntryPoint] = {}
443
451
  for name in sorted(analysable):
444
452
  for symbol, registered in _routes_of(analysable[name]).items():
@@ -447,7 +455,8 @@ def analyze_project(
447
455
  templates = project_templates(root)
448
456
  clearing = models.clearing(escaped)
449
457
  template_calls = models.template_calls(
450
- templates.filters, request_processor(modules[name] for name in sorted(analysable))
458
+ templates.filters,
459
+ request_processor((modules[name], analysable[name].get(ScopeAnalysis)) for name in sorted(analysable)),
451
460
  )
452
461
  for manager in analysable.values():
453
462
  manager.provide(RegisteredRoutes, routes)
@@ -514,15 +523,16 @@ def analyze_project(
514
523
  if pool is not None:
515
524
  pool.shutdown()
516
525
 
517
- index = _seed(results, graph, frozenset())
526
+ index = _index(results, {name: name for name in results})
518
527
  call_graphs: dict[str, CallGraph] = {}
519
528
  functions: dict[str, tuple[ModuleFunction, ...]] = {}
520
529
  for name in sorted(analysable):
521
530
  entry = results[name]
522
531
  findings.extend(entry.findings)
523
532
  unsupported = sum(1 for f in entry.findings if f.rule_id == "unsupported-syntax")
533
+ status = "ambiguous" if files[name].module_name in shared else "analysed"
524
534
  coverage.append(
525
- FileCoverage(str(files[name].source_id), "analysed", len(entry.functions), len(entry.functions) - unsupported)
535
+ FileCoverage(str(files[name].source_id), status, len(entry.functions), len(entry.functions) - unsupported)
526
536
  )
527
537
  functions[name] = entry.functions
528
538
  sites: dict[str, list[CallSite]] = {function.name: [] for function in entry.functions}
@@ -644,24 +654,76 @@ def _advisory_paths(root: Path, advisory_files: Sequence[Path]) -> tuple[Path, .
644
654
  return (*([default] if default.is_file() else []), *advisory_files)
645
655
 
646
656
 
657
+ def _shared_names(graph: ModuleGraph) -> dict[str, list[str]]:
658
+ """The import names several analysable files have, each with those files' modules."""
659
+
660
+ by_name: dict[str, list[str]] = {}
661
+ for name in graph.modules:
662
+ by_name.setdefault(graph.source(name).module_name, []).append(name)
663
+ return {import_name: names for import_name, names in by_name.items() if len(names) > 1}
664
+
665
+
666
+ def _ambiguity_notes(graph: ModuleGraph, shared: Mapping[str, list[str]], root: Path) -> list[Finding]:
667
+ """One note on each file whose import name another file shares, since a symbol of
668
+ that name may denote a function of either, and one on each importer whose import of
669
+ such a name no root resolved, since calls through it are not followed."""
670
+
671
+ def located(name: str) -> str:
672
+ return _located(_path_of(graph.source(name)), root)
673
+
674
+ notes: list[Finding] = []
675
+ for import_name, names in sorted(shared.items()):
676
+ for name in names:
677
+ others = ", ".join(located(other) for other in names if other != name)
678
+ message = (
679
+ f"{located(name)} is the module '{import_name}' like {others}: a symbol "
680
+ f"python.{import_name}.* may name a function of either, and an import of "
681
+ f"'{import_name}' resolves only from its own root"
682
+ )
683
+ notes.append(_note("ambiguous-module", message, graph.source(name), 1))
684
+ for name in graph.modules:
685
+ for import_name in sorted(graph.unresolved(name)):
686
+ candidates = " or ".join(located(other) for other in shared[import_name])
687
+ message = f"'{import_name}' imported here is {candidates}: calls into it are not followed"
688
+ notes.append(_note("ambiguous-module", message, graph.source(name), 1))
689
+ return notes
690
+
691
+
647
692
  def _seed(results: Mapping[str, CachedModule], graph: ModuleGraph, component: frozenset[str]) -> SummaryIndex:
648
693
  """The summaries a component starts from: those of every module it imports,
649
- transitively, all final by the time its wave runs; the whole index for no component."""
650
-
651
- if component:
652
- wanted: set[str] = set()
653
- pending = list(component)
654
- while pending:
655
- for imported in graph.imports(pending.pop()):
656
- if imported in results and imported not in wanted:
657
- wanted.add(imported)
658
- pending.append(imported)
659
- else:
660
- wanted = set(results)
694
+ transitively, all final by the time its wave runs, each under the import name the
695
+ component's code refers to it by. Of several files with one import name, only the
696
+ one a member imports directly, resolved in its own root, goes under that name: the
697
+ others are reached through summaries that inline them already, and a name no
698
+ member's root resolved is followed into neither file."""
699
+
700
+ wanted: set[str] = set()
701
+ pending = list(component)
702
+ while pending:
703
+ for imported in graph.imports(pending.pop()):
704
+ if imported in results and imported not in wanted:
705
+ wanted.add(imported)
706
+ pending.append(imported)
707
+ direct = {imported for member in component for imported in graph.imports(member)}
708
+ by_import: dict[str, list[str]] = {}
709
+ for name in wanted:
710
+ by_import.setdefault(graph.source(name).module_name, []).append(name)
711
+ names: dict[str, str] = {}
712
+ for import_name, modules in by_import.items():
713
+ chosen = modules if len(modules) == 1 else [name for name in modules if name in direct]
714
+ if len(chosen) == 1:
715
+ names[chosen[0]] = import_name
716
+ return _index(results, names)
717
+
718
+
719
+ def _index(results: Mapping[str, CachedModule], names: Mapping[str, str]) -> SummaryIndex:
720
+ """The summaries of the modules in ``names``, each function under the project symbol
721
+ of the module name given for its module."""
722
+
661
723
  return SummaryIndex(
662
724
  {
663
- project_symbol(name, function): summary
664
- for name in sorted(wanted)
725
+ project_symbol(module, function): summary
726
+ for name, module in sorted(names.items())
665
727
  for function, summary in results[name].summaries.items()
666
728
  }
667
729
  )
@@ -674,8 +736,12 @@ def _analyse_managers(
674
736
  affected: Affected,
675
737
  ) -> dict[str, CachedModule]:
676
738
  """Analyse one component: iterate its summaries to a fixpoint over ``seed`` (§21),
677
- then extract what the rest of the run needs from each module."""
739
+ then extract what the rest of the run needs from each module. Each member registers
740
+ its functions under its import name, what the others' code refers to it by; a name
741
+ two members share, a cycle across roots, is registered by neither."""
678
742
 
743
+ counts = Counter(manager.module.name for manager in managers.values())
744
+ names = {name: manager.module.name for name, manager in managers.items() if counts[manager.module.name] == 1}
679
745
  index = seed
680
746
  for manager in managers.values():
681
747
  manager.provide(ProjectSummaries, index)
@@ -684,9 +750,9 @@ def _analyse_managers(
684
750
  {
685
751
  **seed.summaries,
686
752
  **{
687
- project_symbol(name, function): summary
688
- for name, manager in managers.items()
689
- for function, summary in _summaries_of(manager).items()
753
+ project_symbol(module, function): summary
754
+ for name, module in names.items()
755
+ for function, summary in _summaries_of(managers[name]).items()
690
756
  },
691
757
  }
692
758
  )
@@ -701,8 +767,9 @@ def _analyse_managers(
701
767
 
702
768
  @dataclass(frozen=True)
703
769
  class _Batch:
704
- """One component handed to a worker process: it rebuilds the configuration from the
705
- project root and the plugin roots, so only paths and the imported summaries travel."""
770
+ """One component handed to a worker process, its files under the engine's module
771
+ names: it rebuilds the configuration from the project root and the plugin roots, so
772
+ only paths and the imported summaries travel."""
706
773
 
707
774
  root: Path
708
775
  plugin_roots: tuple[Path, ...]
@@ -1,10 +1,12 @@
1
1
  """Analysis coverage: which files and functions a run actually looked at.
2
2
 
3
3
  "No findings" only means something when the reader knows what was analysed. Each file
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. 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.
4
+ is ``analysed``, ``ambiguous`` (analysed, but another file has its Python module name,
5
+ so a symbol of that name may denote a function of either and an import of it resolves
6
+ only from its own root), a ``syntax-error`` (the frontend rejected it) or ``unreadable``
7
+ (it could not be decoded); analysed and ambiguous files count their functions and how
8
+ many lowered. The templates the project names but the run could not read are listed,
9
+ each where the project names it: they may call any template filter.
8
10
  """
9
11
 
10
12
  from __future__ import annotations
@@ -19,6 +19,7 @@ from coretrace_python.interprocedural.modulegraph import (
19
19
  build_module_graph,
20
20
  discover_files,
21
21
  discover_sources,
22
+ name_modules,
22
23
  project_symbol,
23
24
  )
24
25
  from coretrace_python.interprocedural.summaries import (
@@ -73,5 +74,6 @@ __all__ = [
73
74
  "cleared_by",
74
75
  "discover_files",
75
76
  "discover_sources",
77
+ "name_modules",
76
78
  "project_symbol",
77
79
  ]
@@ -132,12 +132,16 @@ class SignaturesAnalysis(Analysis[tuple[Signature, ...]]):
132
132
  @dataclass(frozen=True)
133
133
  class ModuleFunction:
134
134
  """One function of a module as a project plugin sees it: the name the call graph
135
- gives it (``Class.method``, ``outer.inner``, ``<module>``), where it is, and the label
136
- of the entry point it is (``http`` for a route, ``argv`` for a command), if any."""
135
+ gives it (``Class.method``, ``outer.inner``, ``<module>``), where it is, the label
136
+ of the entry point it is (``http`` for a route, ``argv`` for a command), if any, and
137
+ the module-level ``aliases`` bound to it by assigning one name to another
138
+ (``main = actual``), transitively and in source order; a method or a nested
139
+ function has none."""
137
140
 
138
141
  name: str
139
142
  span: SourceSpan
140
143
  entry_point: str | None = None
144
+ aliases: tuple[str, ...] = ()
141
145
 
142
146
 
143
147
  class CallGraph:
@@ -6,12 +6,18 @@ each module imports. Functions defined in the project get project symbols
6
6
  (``python.app.helpers.run``) whose summaries live in a ``SummaryIndex`` that the engine
7
7
  provides to every module's manager, so calls into other files are analysed through
8
8
  summaries rather than by retaining every module's PyIR.
9
+
10
+ The identity of a module is its file. Two files may have the same import name
11
+ (``a/app.py`` and ``z/app.py`` outside any package are both ``app``, each from its own
12
+ directory): the engine then names them by their path from the root, ``a.app`` and
13
+ ``z.app``, and an import of ``app`` resolves within the importer's own root only.
9
14
  """
10
15
 
11
16
  from __future__ import annotations
12
17
 
18
+ from collections import Counter
13
19
  from collections.abc import Iterable, Mapping
14
- from dataclasses import dataclass
20
+ from dataclasses import dataclass, field
15
21
  from pathlib import Path
16
22
  from types import MappingProxyType
17
23
 
@@ -67,6 +73,66 @@ def _is_environment(directory: Path, known: dict[Path, bool]) -> bool:
67
73
  return known[directory]
68
74
 
69
75
 
76
+ def name_modules(root: Path, sources: Iterable[SourceFile]) -> dict[str, SourceFile]:
77
+ """One name per file, in the order given: its import name when no other file has it;
78
+ otherwise its path from ``root`` as a dotted name (``a/app.py`` and ``z/app.py`` are
79
+ ``a.app`` and ``z.app``, a root-level ``manage.py`` stays ``manage``), or that path
80
+ itself when even the dotted names coincide (``x.y/app.py`` and ``x/y/app.py``).
81
+ Decided over the whole set, so the names do not depend on the order the files are
82
+ found in."""
83
+
84
+ found = tuple(sources)
85
+ counts = Counter(source.module_name for source in found)
86
+ shared = [source for source in found if counts[source.module_name] > 1]
87
+ paths = {source.source_id: _relative(root, source) for source in shared}
88
+ dotted = {source_id: _dotted(path) for source_id, path in paths.items()}
89
+ taken = (
90
+ {name for name, count in counts.items() if count == 1}
91
+ | {name for name, count in Counter(dotted.values()).items() if count > 1}
92
+ | {path.as_posix() for path in paths.values()}
93
+ )
94
+ named: dict[str, SourceFile] = {}
95
+ for source in found:
96
+ if counts[source.module_name] == 1:
97
+ named[source.module_name] = source
98
+ elif dotted[source.source_id] not in taken:
99
+ named[dotted[source.source_id]] = source
100
+ else:
101
+ named[paths[source.source_id].as_posix()] = source
102
+ return named
103
+
104
+
105
+ def _relative(root: Path, source: SourceFile) -> Path:
106
+ path = source.path if source.path is not None else Path(str(source.source_id))
107
+ try:
108
+ return path.relative_to(root.resolve())
109
+ except ValueError:
110
+ return path
111
+
112
+
113
+ def _dotted(path: Path) -> str:
114
+ """``a/app.py`` as ``a.app`` and ``pkg/__init__.py`` as ``pkg``; a root-level
115
+ ``__init__.py`` has no dotted name and keeps its path."""
116
+
117
+ parts = path.with_suffix("").parts
118
+ if parts and parts[-1] == "__init__":
119
+ parts = parts[:-1]
120
+ return ".".join(parts) or path.as_posix()
121
+
122
+
123
+ def _import_root(source: SourceFile) -> Path | None:
124
+ """The directory an import of ``source`` by its module name starts from: the parent
125
+ of its top-level package, or its own directory for a module outside any package,
126
+ where ``module_name_for`` stopped. ``None`` for a source not on disk."""
127
+
128
+ if source.path is None:
129
+ return None
130
+ directory = source.path.parent
131
+ while (directory / "__init__.py").is_file():
132
+ directory = directory.parent
133
+ return directory
134
+
135
+
70
136
  def project_symbol(module_name: str, qualified_name: str) -> SymbolId:
71
137
  """The canonical symbol of a project function. A module whose name is not an
72
138
  identifier cannot be imported by that name, but its functions still need stable
@@ -90,6 +156,7 @@ def _component(part: str) -> str:
90
156
  class ModuleGraph:
91
157
  _sources: Mapping[str, SourceFile]
92
158
  _imports: Mapping[str, frozenset[str]]
159
+ _unresolved: Mapping[str, frozenset[str]] = field(default_factory=dict)
93
160
 
94
161
  @property
95
162
  def modules(self) -> tuple[str, ...]:
@@ -104,6 +171,12 @@ class ModuleGraph:
104
171
  def importers(self, name: str) -> frozenset[str]:
105
172
  return frozenset(m for m, imported in self._imports.items() if name in imported)
106
173
 
174
+ def unresolved(self, name: str) -> frozenset[str]:
175
+ """The import names ``name`` imports that several project files have and not
176
+ exactly one in its own root: no edge records them."""
177
+
178
+ return self._unresolved.get(name, frozenset())
179
+
107
180
  def schedule(self) -> tuple[tuple[frozenset[str], ...], ...]:
108
181
  """Strongly connected components in waves: every component of a wave imports,
109
182
  outside itself, only components of earlier waves, so one wave can be analysed
@@ -167,10 +240,17 @@ def build_module_graph(
167
240
  modules: Mapping[str, nodes.Module],
168
241
  imports: Mapping[str, ImportTable],
169
242
  ) -> ModuleGraph:
170
- """Edges from each module to the project modules it imports, in any scope."""
171
-
172
- names = frozenset(sources)
243
+ """Edges from each module to the project modules it imports, in any scope. An import
244
+ names a module by its import name; when several files have that name, it resolves to
245
+ the one in the importer's own root and, without exactly one there, stays unresolved:
246
+ no edge, and the name is recorded on the graph."""
247
+
248
+ by_name: dict[str, list[str]] = {}
249
+ for name, source in sources.items():
250
+ by_name.setdefault(source.module_name, []).append(name)
251
+ roots = {name: _import_root(source) for name, source in sources.items()}
173
252
  edges: dict[str, frozenset[str]] = {}
253
+ unresolved: dict[str, frozenset[str]] = {}
174
254
  for name, module in modules.items():
175
255
  candidates: set[str] = set()
176
256
  dotted_tops: set[str] = set()
@@ -187,11 +267,24 @@ def build_module_graph(
187
267
  candidates.add(path)
188
268
  # The most specific project module each import names; ``app.helpers`` implies
189
269
  # the ``app`` package, which is not an edge worth recording.
190
- found = {
191
- next((m for m in _prefixes(candidate) if m in names), None) for candidate in candidates
192
- }
193
- edges[name] = frozenset(m for m in found if m is not None and m != name)
194
- return ModuleGraph(MappingProxyType(dict(sources)), MappingProxyType(edges))
270
+ found: set[str] = set()
271
+ missing: set[str] = set()
272
+ for candidate in candidates:
273
+ for prefix in _prefixes(candidate):
274
+ matching = by_name.get(prefix)
275
+ if matching is None:
276
+ continue
277
+ if len(matching) > 1:
278
+ matching = [m for m in matching if roots[m] == roots[name]]
279
+ if len(matching) == 1:
280
+ found.add(matching[0])
281
+ else:
282
+ missing.add(prefix)
283
+ break
284
+ edges[name] = frozenset(found - {name})
285
+ if missing:
286
+ unresolved[name] = frozenset(missing)
287
+ return ModuleGraph(MappingProxyType(dict(sources)), MappingProxyType(edges), MappingProxyType(unresolved))
195
288
 
196
289
 
197
290
  def _prefixes(dotted: str) -> list[str]:
@@ -217,5 +310,6 @@ __all__ = [
217
310
  "SummaryIndex",
218
311
  "build_module_graph",
219
312
  "discover_sources",
313
+ "name_modules",
220
314
  "project_symbol",
221
315
  ]
@@ -113,8 +113,8 @@ class ScopeTable:
113
113
  def children(self, scope_id: ScopeId) -> tuple[Scope, ...]:
114
114
  return self._children[scope_id]
115
115
 
116
- def scope_for(self, node: nodes.Function | nodes.Class | nodes.Comprehension) -> Scope:
117
- """Return the scope introduced by a definition or comprehension node."""
116
+ def scope_for(self, node: nodes.Function | nodes.Class | nodes.Lambda | nodes.Comprehension) -> Scope:
117
+ """Return the scope introduced by a definition, lambda or comprehension node."""
118
118
 
119
119
  return self._scopes[self._spans[node.span]]
120
120
 
@@ -835,7 +835,10 @@ def function_entry_point(
835
835
  project: SummaryIndex | None = None,
836
836
  ) -> EntryPoint | None:
837
837
  """The entry point ``function`` is: one of its decorators or, for a method, one of its
838
- class's bases matching an entry-point model, or a registration elsewhere (``routes``)."""
838
+ class's bases matching an entry-point model, a registration elsewhere (``routes``), or
839
+ a model naming the function, or its class, by its own project symbol or by a
840
+ module-level alias of it (``main = actual`` names ``actual`` as ``main``). A model
841
+ naming the method wins over one naming its class, whichever name it uses."""
839
842
 
840
843
  owner = _owner(module, function)
841
844
  enclosing_function = _enclosing(module, function)
@@ -843,12 +846,61 @@ def function_entry_point(
843
846
  # ``app = Flask(__name__)`` inside ``create_app``: routes defined there resolve.
844
847
  instances = {**(instances or {}), **local_instances(enclosing_function, scopes, symbols)}
845
848
  entry = entry_point_of(function, models, scopes, symbols, owner, instances, project)
846
- if entry is None and routes:
847
- qualified = function.name if owner is None else f"{owner.name}.{function.name}"
848
- entry = routes.get(project_symbol(module.name, qualified))
849
- if entry is None and owner is not None:
850
- entry = routes.get(project_symbol(module.name, owner.name))
851
- return entry
849
+ if entry is not None:
850
+ return entry
851
+ defined = function if owner is None else owner
852
+ names = (defined.name, *_aliases(module, defined.name))
853
+ qualified = names if owner is None else (*(f"{name}.{function.name}" for name in names), *names)
854
+ for name in qualified:
855
+ entry = _declared(project_symbol(module.name, name), models, routes)
856
+ if entry is not None:
857
+ return entry
858
+ return None
859
+
860
+
861
+ def _declared(symbol: SymbolId, models: ModelTable, routes: Routes | None) -> EntryPoint | None:
862
+ """The entry point a project ``symbol`` is: registered as a handler, or named by a
863
+ model."""
864
+
865
+ registered = routes.get(symbol) if routes else None
866
+ return registered if registered is not None else models.entry_point(symbol)
867
+
868
+
869
+ def _aliases(module: nodes.Module, name: str) -> Iterable[str]:
870
+ """The module-level names bound to ``name`` by assigning one name to another,
871
+ transitively and in source order: ``handler = actual`` then ``main = handler`` make
872
+ both aliases of ``actual``. A name bound inside a function or class, or to anything
873
+ but a name (a call, an import), is no alias."""
874
+
875
+ aliases: dict[str, list[str]] = {}
876
+ for statement in _module_level(module.body):
877
+ if isinstance(statement, nodes.Assign) and isinstance(statement.target, nodes.Name) and isinstance(statement.value, nodes.Name):
878
+ aliases.setdefault(statement.value.identifier, []).append(statement.target.identifier)
879
+ found = {name}
880
+ pending = [name]
881
+ while pending:
882
+ for alias in aliases.get(pending.pop(0), ()):
883
+ if alias not in found:
884
+ found.add(alias)
885
+ pending.append(alias)
886
+ yield alias
887
+
888
+
889
+ def _module_level(body: Iterable[nodes.Statement]) -> Iterable[nodes.Statement]:
890
+ """The statements executed at module level: the body and what its compound
891
+ statements hold, not what a function or class defines."""
892
+
893
+ for statement in body:
894
+ if isinstance(statement, nodes.Function | nodes.Class):
895
+ continue
896
+ yield statement
897
+ for attribute in ("body", "orelse", "finalbody"):
898
+ nested = getattr(statement, attribute, None)
899
+ if isinstance(nested, tuple):
900
+ yield from _module_level(nested)
901
+ if isinstance(statement, nodes.Try):
902
+ for handler in statement.handlers:
903
+ yield from _module_level(handler.body)
852
904
 
853
905
 
854
906
  def _owner(module: nodes.Module, function: nodes.Function) -> nodes.Class | None:
@@ -891,6 +943,8 @@ def parameter_sources(
891
943
  for index in range(first, len(function.parameters)):
892
944
  if index - first < len(roles) and roles[index - first] is not None:
893
945
  continue
946
+ if entry.inputs is not None and index - first not in entry.inputs:
947
+ continue
894
948
  annotation = function.parameters[index].annotation
895
949
  text = (request is not None and index > first) or (
896
950
  annotation is not None and _scalar(annotation, symbols, enclosing)
@@ -1098,7 +1152,9 @@ class EntryPointAnalysis(Analysis[tuple[ModuleFunction, ...]]):
1098
1152
  functions: list[ModuleFunction] = []
1099
1153
  for name, function in graph.definitions.items():
1100
1154
  entry = function_entry_point(function, ctx.module, models, scopes, symbols, instances, routes, project)
1101
- functions.append(ModuleFunction(name, function.span, entry.label if entry is not None else None))
1155
+ label = entry.label if entry is not None else None
1156
+ # A dotted name, a method's or a nested function's, is bound by no assignment.
1157
+ functions.append(ModuleFunction(name, function.span, label, tuple(_aliases(ctx.module, name))))
1102
1158
  return tuple(functions)
1103
1159
 
1104
1160
 
@@ -110,12 +110,17 @@ class Sanitizer:
110
110
 
111
111
  @dataclass(frozen=True)
112
112
  class EntryPoint:
113
- """Functions decorated by ``symbol``, and methods of classes deriving from it,
114
- receive attacker-controlled parameters."""
113
+ """Functions decorated by ``symbol``, methods of classes deriving from it, and, when
114
+ ``symbol`` names a project function or class, that function or the class's methods
115
+ receive attacker-controlled parameters: those at ``inputs``, by position after
116
+ ``self`` for a method, or every parameter when ``inputs`` is None. A deployment
117
+ declaration an integration reads names a Lambda handler this way, whose event is
118
+ input and whose context is not."""
115
119
 
116
120
  symbol: SymbolId
117
121
  label: str
118
122
  kinds: TaintKind = TaintKind.ALL
123
+ inputs: tuple[int, ...] | None = None
119
124
 
120
125
 
121
126
  @dataclass(frozen=True)
@@ -15,7 +15,8 @@ it extends or includes, can output data unescaped:
15
15
 
16
16
  Templates are found under the project's ``templates`` directories, by their path below
17
17
  one. A template the engine cannot find — named by an expression, under a ``DIRS`` entry
18
- of another name, shipped by an installed package — is never assumed to escape.
18
+ of another name, shipped by an installed package — or cannot read is never assumed to
19
+ escape.
19
20
 
20
21
  A template applying a filter of Django's own libraries calls the function behind it
21
22
  whenever it is rendered, though no Python call names that function: ``{{ bio|striptags }}``
@@ -23,18 +24,30 @@ calls ``django.template.defaultfilters.striptags``. Every template found counts,
23
24
  or not a Python call names it, since a class-based view renders its ``template_name``
24
25
  inside Django. A template the engine cannot read may call any filter; the engine says
25
26
  where the project names one.
27
+
28
+ A project tag library, a module of a ``templatetags`` directory, replaces a filter of
29
+ that name in every template loading it before applying the filter, as Django's parser
30
+ does: ``{% load custom %}{{ bio|striptags }}`` calls the project's ``striptags`` when
31
+ ``custom.py`` registers one, a later load overrides an earlier one, and
32
+ ``{% load striptags from custom %}`` selects. The engine reads what a library registers
33
+ on its ``register`` with the semantic layers, and where it cannot tell — no such library,
34
+ a file it cannot read or understand, several apps giving the name and disagreeing — the
35
+ filter is neither the built-in nor certainly replaced: no call is claimed, and the load
36
+ is listed among what the engine could not read. ``OPTIONS["builtins"]`` and
37
+ ``OPTIONS["libraries"]`` of the settings are not read.
26
38
  """
27
39
 
28
40
  from __future__ import annotations
29
41
 
30
42
  import re
31
- from collections.abc import Iterable, Iterator, Mapping
43
+ from collections.abc import Container, Iterable, Iterator, Mapping
32
44
  from dataclasses import dataclass, field
33
45
  from pathlib import Path
34
46
  from types import MappingProxyType
35
47
  from typing import ClassVar
36
48
 
37
49
  from coretrace_python.analysis import Analysis, AnalysisContext
50
+ from coretrace_python.frontend import HIRBuildError, ParseError, build_hir
38
51
  from coretrace_python.hir import nodes
39
52
  from coretrace_python.hir.visitors import Node, children
40
53
  from coretrace_python.interprocedural import (
@@ -43,11 +56,21 @@ from coretrace_python.interprocedural import (
43
56
  TemplateFilter,
44
57
  discover_files,
45
58
  )
46
- from coretrace_python.semantic.symbols import SymbolId
47
- from coretrace_python.source import SourceId, SourceSpan
59
+ from coretrace_python.semantic.imports import ImportResolutionError, analyze_imports
60
+ from coretrace_python.semantic.scopes import (
61
+ BindingKind,
62
+ ResolutionKind,
63
+ ScopeError,
64
+ ScopeId,
65
+ ScopeTable,
66
+ analyze_scopes,
67
+ )
68
+ from coretrace_python.semantic.symbols import SymbolId, analyze_symbols
69
+ from coretrace_python.source import SourceId, SourceManager, SourceSpan
48
70
  from coretrace_python.taint.models import ModelTable
49
71
 
50
72
  TEMPLATES_DIRECTORY = "templates"
73
+ TEMPLATETAGS_DIRECTORY = "templatetags"
51
74
 
52
75
  _OUTPUT, _TAG, _URL = "\x00", "\x01", "/u"
53
76
  _SYNTAX = re.compile(r"{{(.*?)}}|{%(.*?)%}|{#.*?#}", re.DOTALL)
@@ -101,6 +124,10 @@ FILTER_FUNCTIONS: Mapping[str, SymbolId] = MappingProxyType(
101
124
  # Every filter but ``safe`` and ``safeseq`` escapes its input, or returns it for
102
125
  # autoescaping to escape, when autoescaping is on.
103
126
  _FILTERS = frozenset(FILTER_FUNCTIONS) - {"safe", "safeseq"}
127
+ # The class a project tag library binds ``register`` to, by either of its paths, and
128
+ # its methods registering tags, which register no filter.
129
+ _LIBRARY_CLASSES = frozenset(SymbolId(f"python.django.template.{p}") for p in ("Library", "library.Library"))
130
+ _TAG_REGISTRARS = frozenset({"tag", "tag_function", "simple_tag", "simple_block_tag", "inclusion_tag"})
104
131
 
105
132
  _URL_ATTRIBUTES = frozenset(
106
133
  {
@@ -130,6 +157,8 @@ _BINDING = re.compile(r"(\w+)=")
130
157
  # the request context processor adds gives (``request.GET``).
131
158
  _VARIABLE = re.compile(r"([A-Za-z_]\w*)(?:\.(\w+))?")
132
159
  _LITERALS = frozenset({"True", "False", "None"})
160
+ # The variable a block renders to keep the parent's content, as ``_chain`` reads it.
161
+ _BLOCK_SUPER = "block.super"
133
162
 
134
163
  REQUEST_PROCESSOR = "django.template.context_processors.request"
135
164
  _DJANGO_ENGINE = "django.template.backends.django.DjangoTemplates"
@@ -149,7 +178,7 @@ def escaped_templates(root: Path) -> frozenset[str]:
149
178
  local: dict[str, bool] = {}
150
179
  requires: dict[str, set[str]] = {}
151
180
  for name, paths in found.items():
152
- verdicts = [_inspect(_read(path)) for path in paths]
181
+ verdicts = [_verdict(path) for path in paths]
153
182
  local[name] = all(safe for safe, _ in verdicts)
154
183
  requires[name] = {needed for _, needs in verdicts for needed in needs}
155
184
  escaped = {name for name, safe in local.items() if safe}
@@ -171,6 +200,14 @@ def _read(path: Path) -> str:
171
200
  return _text(path) or ""
172
201
 
173
202
 
203
+ def _verdict(path: Path) -> tuple[bool, set[str]]:
204
+ """Whether one template file escapes all it prints, and what it needs: a file the
205
+ engine cannot read is not established to escape, like a template it cannot find."""
206
+
207
+ text = _text(path)
208
+ return (False, set()) if text is None else _inspect(text)
209
+
210
+
174
211
  def _text(path: Path) -> str | None:
175
212
  """The text of ``path``, or None when it cannot be read."""
176
213
 
@@ -305,10 +342,11 @@ class FilterCall:
305
342
  @dataclass(frozen=True)
306
343
  class ProjectTemplates:
307
344
  """The templates found under the project's ``templates`` directories: their
308
- ``names``, as a render call names them; the filters they apply, as ``calls``; where
309
- they name a template the engine cannot read, as ``unread``; and, by name, the filter
310
- calls rendering each template makes with the context keys reaching them, as
311
- ``filters``."""
345
+ ``names``, as a render call names them; the filters of Django's own libraries they
346
+ apply, as ``calls``, unless a project library they load replaces the filter; where
347
+ they name a template the engine cannot read, or load a library it cannot read while
348
+ applying a filter of that name, as ``unread``; and, by name, the filter calls
349
+ rendering each template makes with the context keys reaching them, as ``filters``."""
312
350
 
313
351
  names: frozenset[str] = frozenset()
314
352
  calls: tuple[FilterCall, ...] = ()
@@ -354,13 +392,44 @@ class _Reference:
354
392
  return frozenset(found)
355
393
 
356
394
 
395
+ @dataclass(frozen=True)
396
+ class _Load:
397
+ """A ``{% load %}`` of ``library`` at ``offset``, of the ``names`` it selects
398
+ (``{% load a b from lib %}``), None for the whole library."""
399
+
400
+ library: str
401
+ names: frozenset[str] | None
402
+ offset: int
403
+
404
+
405
+ @dataclass(frozen=True)
406
+ class _Library:
407
+ """What the project's tag library files of one name register: the filter names they
408
+ surely register, ``certain``, and those they may register, ``uncertain`` — None when
409
+ they may register anything, as a file the engine cannot read or understand may."""
410
+
411
+ certain: frozenset[str] = frozenset()
412
+ uncertain: frozenset[str] | None = None
413
+
414
+ def overrides(self, name: str) -> bool | None:
415
+ """Whether loading the library replaces the filter ``name``; None when it may."""
416
+
417
+ if name in self.certain:
418
+ return True
419
+ return None if self.uncertain is None or name in self.uncertain else False
420
+
421
+
357
422
  @dataclass(frozen=True)
358
423
  class _Template:
359
- """What one template file applies and references, and the ``blocks`` it defines."""
424
+ """What one template file applies and references, the ``blocks`` it defines, and
425
+ among them the ``extended`` ones, which render ``{{ block.super }}``, and the loads
426
+ under which it applies a filter the engine could not resolve."""
360
427
 
361
428
  applications: tuple[_Application, ...]
362
429
  references: tuple[_Reference, ...]
363
430
  blocks: frozenset[str]
431
+ extended: frozenset[str] = frozenset()
432
+ unresolved: tuple[_Load, ...] = ()
364
433
 
365
434
  def rendered(self, blocks: tuple[str, ...], overridden: frozenset[str]) -> bool:
366
435
  """Whether what stands in ``blocks`` renders when a template extending this one
@@ -370,15 +439,25 @@ class _Template:
370
439
  extends = any(reference.extends for reference in self.references)
371
440
  return not overridden.intersection(blocks) and (bool(blocks) or not extends)
372
441
 
442
+ @property
443
+ def replaced(self) -> frozenset[str]:
444
+ """The blocks this template replaces in what it extends: those it defines
445
+ without rendering ``{{ block.super }}``, which keeps the parent's content."""
446
+
447
+ return self.blocks - self.extended
448
+
373
449
 
374
450
  def project_templates(root: Path) -> ProjectTemplates:
375
451
  """What the templates under ``root`` call, and the templates they include or extend
376
452
  that the engine cannot read: named by an expression, or by a name found under no
377
- ``templates`` directory. A template file that cannot be read is unread too. A name
453
+ ``templates`` directory. A template file that cannot be read is unread too, and so
454
+ is a template loading a library the engine cannot read, or find, or finds in several
455
+ apps disagreeing, where it then applies a filter of that library's name. A name
378
456
  found in several files gives no ``filters``: which one renders depends on the
379
457
  loaders."""
380
458
 
381
459
  found = {path: tuple(_names(path.relative_to(root).parts)) for path in discover_files(root)}
460
+ libraries = _libraries(found)
382
461
  templates = sorted(path for path, names in found.items() if names)
383
462
  files: dict[str, list[Path]] = {}
384
463
  for path in templates:
@@ -392,13 +471,17 @@ def project_templates(root: Path) -> ProjectTemplates:
392
471
  if text is None:
393
472
  unread.append(f"{relative}, unreadable")
394
473
  continue
395
- read[path] = _read_scopes(text, SourceId(str(path)))
474
+ read[path] = _read_scopes(text, SourceId(str(path)), libraries)
396
475
  for reference in read[path].references:
397
476
  where = f"{relative}:{_position(text, reference.offset)[0]}"
398
477
  if reference.name is None:
399
478
  unread.append(f"{where} names a template by an expression")
400
479
  elif reference.name not in files:
401
480
  unread.append(f"{where} names {reference.name!r}, not found")
481
+ for load in read[path].unresolved:
482
+ library = libraries.get(load.library)
483
+ reason = "not found" if library is None else "not read" if library.uncertain is None else "found in several files"
484
+ unread.append(f"{relative}:{_position(text, load.offset)[0]} loads {load.library!r}, {reason}")
402
485
  calls = tuple(FilterCall(a.symbol, a.span) for template in read.values() for a in template.applications)
403
486
  filters = {
404
487
  name: tuple(
@@ -435,7 +518,7 @@ def _reaching(
435
518
  if reference.name is None:
436
519
  continue
437
520
  if reference.extends:
438
- found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.blocks)
521
+ found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.replaced)
439
522
  elif template.rendered(reference.blocks, overridden):
440
523
  found = _reaching(reference.name, files, read, visiting | {name})
441
524
  else:
@@ -444,18 +527,23 @@ def _reaching(
444
527
  yield symbol, span, where, reference.maps(value), reference.maps(argument)
445
528
 
446
529
 
447
- def _read_scopes(text: str, source: SourceId) -> _Template:
530
+ def _read_scopes(text: str, source: SourceId, libraries: Mapping[str, _Library]) -> _Template:
448
531
  """The filters a template applies and the templates it includes or extends, with the
449
532
  names free in it that reach each: through ``{% for %}``, ``{% with %}`` and the
450
533
  ``as`` of other tags, which bind a name until their block ends, the arguments of
451
- earlier filters, and the output of ``{% filter %}`` blocks."""
534
+ earlier filters, and the output of ``{% filter %}`` blocks. A filter is the one of
535
+ Django's own libraries unless a project library, among ``libraries``, loaded before
536
+ it in the file replaces it, as Django's parser resolves filters."""
452
537
 
453
538
  applications: list[_Application] = []
454
539
  references: list[_Reference] = []
455
540
  opened: list[str] = []
456
541
  defined: set[str] = set()
542
+ extended: set[str] = set()
457
543
  scopes: list[dict[str, frozenset[str]]] = [{}]
458
544
  blocks: list[tuple[list[tuple[str, int, frozenset[str]]], set[str]]] = []
545
+ loaded: list[_Load] = []
546
+ unresolved: dict[_Load, None] = {}
459
547
 
460
548
  def free(names: frozenset[str]) -> frozenset[str]:
461
549
  found: set[str] = set()
@@ -465,14 +553,36 @@ def _read_scopes(text: str, source: SourceId) -> _Template:
465
553
  found |= {name} if bound is None else _through(bound, name)
466
554
  return frozenset(found)
467
555
 
556
+ def behind(name: str) -> SymbolId | _Load | None:
557
+ """The function behind the filter ``name`` applied here: the one of Django's own
558
+ libraries, unless the latest load so far of a library registering ``name``
559
+ replaces it (None), or may (that load). Django's own libraries register no name
560
+ of another, so their loads change nothing."""
561
+
562
+ builtin = FILTER_FUNCTIONS.get(name)
563
+ if builtin is None:
564
+ return None
565
+ for load in reversed(loaded):
566
+ if (load.names is not None and name not in load.names) or load.library in _LIBRARIES:
567
+ continue
568
+ library = libraries.get(load.library)
569
+ verdict = library.overrides(name) if library is not None else None
570
+ if verdict is None:
571
+ return load
572
+ if verdict:
573
+ return None
574
+ return builtin
575
+
468
576
  def apply(head: frozenset[str], chain: list[tuple[str, int, frozenset[str]]]) -> frozenset[str]:
469
577
  """Record the filters of ``chain`` applied to a value the free names ``head``
470
578
  reach; the free names reaching the value of the whole expression."""
471
579
 
472
580
  value = head
473
581
  for name, offset, argument in chain:
474
- symbol = FILTER_FUNCTIONS.get(name)
475
- if symbol is not None:
582
+ symbol = behind(name)
583
+ if isinstance(symbol, _Load):
584
+ unresolved[symbol] = None
585
+ elif symbol is not None:
476
586
  line, column = _position(text, offset)
477
587
  span = SourceSpan(source, line, column)
478
588
  applications.append(_Application(symbol, span, value, free(argument), tuple(opened)))
@@ -482,6 +592,8 @@ def _read_scopes(text: str, source: SourceId) -> _Template:
482
592
  for offset, expression, tag in _expressions(text):
483
593
  if not tag:
484
594
  head, chain = _chain(expression, offset)
595
+ if opened and _BLOCK_SUPER in head:
596
+ extended.add(opened[-1])
485
597
  printed = apply(free(head), chain)
486
598
  for _, collected in blocks:
487
599
  collected |= printed
@@ -534,10 +646,145 @@ def _read_scopes(text: str, source: SourceId) -> _Template:
534
646
  else:
535
647
  context = {k: v for scope in scopes for k, v in scope.items()}
536
648
  references.append(_Reference(named, offset, bindings, "only" in words, context, tuple(opened)))
649
+ elif name == "load":
650
+ selected = frozenset(words[1 : words.index("from")]) if "from" in words else None
651
+ loaded.extend(_Load(library, selected, offset) for library in _loaded(words[1:]))
537
652
  elif "as" in words[1:-1]:
538
653
  target = words[words.index("as", 1) + 1]
539
654
  scopes[-1][target] = values.get(words[1], frozenset()) if name == "regroup" else frozenset()
540
- return _Template(tuple(applications), tuple(references), frozenset(defined))
655
+ return _Template(
656
+ tuple(applications), tuple(references), frozenset(defined), frozenset(extended), tuple(unresolved)
657
+ )
658
+
659
+
660
+ def _libraries(paths: Iterable[Path]) -> dict[str, _Library]:
661
+ """The project's tag libraries among ``paths``, by the name ``{% load %}`` gives
662
+ them: the modules of the ``templatetags`` directories. Of several files of one name,
663
+ in different apps, only what every file registers is certain, and one the engine
664
+ cannot read leaves everything uncertain."""
665
+
666
+ readings: dict[str, list[frozenset[str] | None]] = {}
667
+ for path in paths:
668
+ if path.suffix == ".py" and path.parent.name == TEMPLATETAGS_DIRECTORY and path.stem != "__init__":
669
+ readings.setdefault(path.stem, []).append(_registered_filters(path))
670
+ libraries: dict[str, _Library] = {}
671
+ for name, found in readings.items():
672
+ read = [names for names in found if names is not None]
673
+ if len(read) < len(found):
674
+ libraries[name] = _Library()
675
+ else:
676
+ certain = read[0].intersection(*read[1:])
677
+ libraries[name] = _Library(certain, read[0].union(*read[1:]) - certain)
678
+ return libraries
679
+
680
+
681
+ def _registered_filters(path: Path) -> frozenset[str] | None:
682
+ """The names of the filters the tag library module ``path`` registers, or None when
683
+ the engine cannot tell them all: the file cannot be read or parsed, ``register`` is
684
+ not bound to a ``Library`` at module level, a registration's name is not written as
685
+ a constant, or the library is filled or passed around in a way the engine does not
686
+ read. The ``Library`` is known by its symbol, whatever the import spelling, and a
687
+ registration under ``if``, ``try`` or a function counts."""
688
+
689
+ try:
690
+ module = build_hir(SourceManager().load_file(path))
691
+ scopes = analyze_scopes(module)
692
+ symbols = analyze_symbols(scopes, analyze_imports(module, scopes), module)
693
+ except (OSError, UnicodeDecodeError, ParseError, HIRBuildError, ScopeError, ImportResolutionError):
694
+ return None
695
+ scope = scopes.module_scope.id
696
+ if symbols.resolve(scope, "register") not in _LIBRARY_CLASSES:
697
+ return None
698
+ functions = {name for name, bound in scopes.module_scope.bindings.items() if bound.kind is BindingKind.FUNCTION}
699
+ registered: set[str] = set()
700
+
701
+ def record(name: str | None) -> bool:
702
+ if name is not None:
703
+ registered.add(name)
704
+ return name is not None
705
+
706
+ def visit(node: Node) -> bool:
707
+ """Read the registrations under ``node``; False when one cannot be read."""
708
+
709
+ if isinstance(node, nodes.Function):
710
+ for decorator in node.decorators:
711
+ if _library_attribute(symbols.resolve_expression(scope, decorator)) == "filter":
712
+ named = _decorated(decorator, node.name) if isinstance(decorator, nodes.Call) else node.name
713
+ if not record(named):
714
+ return False
715
+ elif not visit(decorator):
716
+ return False
717
+ return all(map(visit, (*node.parameters, *node.body)))
718
+ if isinstance(node, nodes.Call) and isinstance(node.callee, nodes.Name | nodes.Attribute):
719
+ callee = symbols.resolve_expression(scope, node.callee)
720
+ if callee in _LIBRARY_CLASSES:
721
+ return not (node.arguments or node.keywords) # ``Library(filters=...)`` fills it unseen
722
+ if _library_attribute(callee) == "filter":
723
+ return record(_registered(node, functions)) and all(map(visit, (*node.arguments, *node.keywords)))
724
+ if isinstance(node, nodes.Name | nodes.Attribute):
725
+ symbol = symbols.resolve_expression(scope, node)
726
+ if _library_attribute(symbol) in _TAG_REGISTRARS:
727
+ return True
728
+ if symbol in _LIBRARY_CLASSES or _library_attribute(symbol) is not None:
729
+ return False # the library, or its filters, in hands the engine does not follow
730
+ if isinstance(node, nodes.Assign) and isinstance(node.target, nodes.Name):
731
+ return visit(node.value)
732
+ return all(map(visit, children(node)))
733
+
734
+ return frozenset(registered) if all(map(visit, module.body)) else None
735
+
736
+
737
+ def _library_attribute(symbol: SymbolId | None) -> str | None:
738
+ """The attribute of a ``Library`` that ``symbol`` names, ``filter`` for
739
+ ``register.filter``; None for any other symbol."""
740
+
741
+ if symbol is None:
742
+ return None
743
+ owner, _, name = symbol.canonical_name.rpartition(".")
744
+ return name if "." in owner and SymbolId(owner) in _LIBRARY_CLASSES else None
745
+
746
+
747
+ def _decorated(call: nodes.Call, function: str) -> str | None:
748
+ """What ``@register.filter(...)`` registers ``function`` as: the constant ``name`` it
749
+ gives, or ``function`` itself when it gives only flags; None when the engine cannot
750
+ tell."""
751
+
752
+ given = _arguments(call, "name")
753
+ if given is None:
754
+ return None
755
+ return function if given[0] is None else _string(given[0])
756
+
757
+
758
+ def _registered(call: nodes.Call, functions: Container[str]) -> str | None:
759
+ """What ``register.filter(...)`` registers when called as a statement: the constant
760
+ name of ``register.filter("x", fn)`` and ``register.filter(name="x", filter_func=fn)``,
761
+ the function's own name for ``register.filter(fn)`` when the module defines
762
+ ``fn``; None when the engine cannot tell."""
763
+
764
+ given = _arguments(call, "name", "filter_func")
765
+ if given is None:
766
+ return None
767
+ name, function = given
768
+ if function is None:
769
+ return name.identifier if isinstance(name, nodes.Name) and name.identifier in functions else None
770
+ return _string(name)
771
+
772
+
773
+ def _arguments(call: nodes.Call, *parameters: str) -> list[nodes.Expression | None] | None:
774
+ """The argument ``call`` gives each of ``parameters``, by position or keyword; None
775
+ when it unpacks any, which could give them too."""
776
+
777
+ if any(isinstance(a, nodes.Starred) for a in call.arguments) or any(k.name is None for k in call.keywords):
778
+ return None
779
+ keywords = {keyword.name: keyword.value for keyword in call.keywords}
780
+ return [
781
+ call.arguments[position] if position < len(call.arguments) else keywords.get(name)
782
+ for position, name in enumerate(parameters)
783
+ ]
784
+
785
+
786
+ def _string(expression: nodes.Expression | None) -> str | None:
787
+ return expression.value if isinstance(expression, nodes.Constant) and isinstance(expression.value, str) else None
541
788
 
542
789
 
543
790
  def _chain(expression: str, offset: int) -> tuple[frozenset[str], list[tuple[str, int, frozenset[str]]]]:
@@ -569,29 +816,41 @@ def _through(bound: frozenset[str], name: str) -> frozenset[str]:
569
816
  return frozenset(".".join(f"{b}.{attribute}".split(".")[:2]) if attribute else b for b in bound)
570
817
 
571
818
 
572
- def request_processor(modules: Iterable[nodes.Module]) -> bool:
819
+ def request_processor(modules: Iterable[tuple[nodes.Module, ScopeTable]]) -> bool:
573
820
  """Whether every Django template engine the project's settings configure runs the
574
- ``request`` context processor, for certain: each assignment to ``TEMPLATES`` is a
575
- literal list of literal engines, at least one of them ``DjangoTemplates``, and each
576
- of those lists the processor in a literal ``context_processors``; no other code
577
- names ``TEMPLATES``, which could change it. A mere mention of the processor proves
578
- nothing."""
821
+ ``request`` context processor, for certain: each module-level assignment to
822
+ ``TEMPLATES`` is a literal list of literal engines, at least one of them
823
+ ``DjangoTemplates``, and each of those lists the processor in a literal
824
+ ``context_processors``; no other code names the module's ``TEMPLATES``, or a
825
+ ``TEMPLATES`` it does not bind (a star import), which could change it. A
826
+ ``TEMPLATES`` bound in a function or a class is another name, which configures
827
+ nothing and changes nothing. A mere mention of the processor proves nothing."""
579
828
 
580
829
  assignments = mentions = 0
581
- for module in modules:
582
- stack: list[Node] = list(module.body)
583
- while stack:
584
- node = stack.pop()
585
- stack.extend(children(node))
830
+ for module, scopes in modules:
831
+ for node, scope in _scoped(module, scopes):
586
832
  if isinstance(node, nodes.Name) and node.identifier == _ENGINES_SETTING:
587
- mentions += 1
588
- elif isinstance(node, nodes.Assign) and _names_engines(node.target):
833
+ if scopes.resolve(scope, node.identifier).kind in (ResolutionKind.GLOBAL, ResolutionKind.UNBOUND):
834
+ mentions += 1
835
+ elif isinstance(node, nodes.Assign) and _names_engines(node.target) and scope == scopes.module_scope.id:
589
836
  if not _runs_request_processor(node.value):
590
837
  return False
591
838
  assignments += 1
592
839
  return assignments > 0 and mentions == assignments
593
840
 
594
841
 
842
+ def _scoped(module: nodes.Module, scopes: ScopeTable) -> Iterator[tuple[Node, ScopeId]]:
843
+ """Every node of ``module`` with the scope it stands in."""
844
+
845
+ stack: list[tuple[Node, ScopeId]] = [(node, scopes.module_scope.id) for node in module.body]
846
+ while stack:
847
+ node, scope = stack.pop()
848
+ yield node, scope
849
+ if isinstance(node, nodes.Function | nodes.Lambda | nodes.Class | nodes.Comprehension):
850
+ scope = scopes.scope_for(node).id
851
+ stack.extend((child, scope) for child in children(node))
852
+
853
+
595
854
  def _names_engines(target: nodes.Expression) -> bool:
596
855
  return isinstance(target, nodes.Name) and target.identifier == _ENGINES_SETTING
597
856
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coretrace-python-analyzer
3
- Version: 0.17.0
3
+ Version: 0.19.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=HRmDGjpSkbWNTv_zUrVcohlicTxeBUblXGvOBz0lia4,76
1
+ coretrace_python/__init__.py,sha256=FWsRdqY0ZoJRj8rA--2DntUA3GU1JsrZdyrjQIAq5wE,76
2
2
  coretrace_python/__main__.py,sha256=V8DnYtRboISK2VCKm7DGHLREKvqLbqApyWlKBOs26wU,65
3
- coretrace_python/cache.py,sha256=-pZv-ELm4jMCTXQWSZIobHaT9UHIjFu7IlMkOM0i4LI,13607
3
+ coretrace_python/cache.py,sha256=XnUC677v8VQvlmLA_GQO376isHn6ZdSOOA0dRua0Foo,13716
4
4
  coretrace_python/cli.py,sha256=G5jhb6g6RgSRSjjS7DBgziIjUM1vTFwalJGvFHVKxrg,11225
5
- coretrace_python/engine.py,sha256=13lCa7rJTI6Q4Icyvs8gZpcJhW9WxO1poWXGjWCDHT0,33922
5
+ coretrace_python/engine.py,sha256=i_YckOVHZa_Mcwxgi2jsMkw8O49zHFQZ313mA_Xtl-4,37643
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
@@ -102,7 +102,7 @@ coretrace_python/dependency/sbom.py,sha256=rzJ-t0Mwoghej8nk0N63Dpjr_wYXm6JzuOPXj
102
102
  coretrace_python/dependency/vex.py,sha256=bafWyziWWQP-Fjw1bvyOcOPsdFMwKy-sjMGWL7IPY3w,8048
103
103
  coretrace_python/findings/__init__.py,sha256=yFKXSFEsAnFSxF1E_ptF2OH4YlUcCaSeIlWSvqMgaHo,500
104
104
  coretrace_python/findings/baseline.py,sha256=fbGQuULRsCjhxSNdwuLfwsvql9ZUfF3jqCXrRmIgOoQ,3468
105
- coretrace_python/findings/coverage.py,sha256=c7-NXdESYCzJWsSDx5EhflY6Lr7yr0XSxwOMPUDEE18,1385
105
+ coretrace_python/findings/coverage.py,sha256=LT74vTEjnt_kAgWBEt60rM0sB6mFAyIVzhta0WNUkxE,1580
106
106
  coretrace_python/findings/model.py,sha256=zRK1FDuLgiVY4ch6H_AN9rDxC1orNW4wc8YN3cfVFfc,1424
107
107
  coretrace_python/findings/provenance.py,sha256=vP3nbyDPMlRlxVcVlurF0mSGNuxO3B7kzDDDd_BVws0,838
108
108
  coretrace_python/findings/refutation.py,sha256=UeYCz70LwyL8AMimhs3ys9cH0j1_YbdHVu7y3VvmmUg,22026
@@ -113,9 +113,9 @@ coretrace_python/frontend/parser.py,sha256=6-CG7rWxUYeOGeDP206SJWy4BMQUP9Mr6Jf-t
113
113
  coretrace_python/hir/__init__.py,sha256=xMpvj-RSp6bd78a_hhkqbe99-snjERkH-_Lv5o9ASbw,180
114
114
  coretrace_python/hir/nodes.py,sha256=4BI0f3UhKbnz9ZIxMoKXM7ujav0BmRpD0OXaoblRxIk,12364
115
115
  coretrace_python/hir/visitors.py,sha256=JFnX09hK6trLyRcWgZ5ehOG2ywX3VvkqQ1h82nahYNg,885
116
- coretrace_python/interprocedural/__init__.py,sha256=HvV9zibV_VX8cfIYzWXVEDGkkMZzDywx-lKJioYSFtg,1566
117
- coretrace_python/interprocedural/callgraph.py,sha256=a7fTvciUjMkEO0TsJqZm6L0OFyARmLQ1P2cjAnBTENk,21948
118
- coretrace_python/interprocedural/modulegraph.py,sha256=4sB7OKjBYfxtm7aMtHm2jRlqFVUNUsdnLhnv9UZ1BWA,8846
116
+ coretrace_python/interprocedural/__init__.py,sha256=q8u1eIpM55wqVtn46cxuymOn5D5DWU7K35i8Nzzbc3w,1604
117
+ coretrace_python/interprocedural/callgraph.py,sha256=EEG8kh9yyFPZiiFcouR8wuIwicClSKHu-z7nLh1kKOQ,22163
118
+ coretrace_python/interprocedural/modulegraph.py,sha256=xKlO_L2mcZi0RoG9h2ulacYecqUhM5BG1RrdC6_HUk8,13036
119
119
  coretrace_python/interprocedural/summaries.py,sha256=6lybEynjtptZGGBw_3WKXx9ZZZqN96xL-r-tzo8fbMI,33862
120
120
  coretrace_python/ir/__init__.py,sha256=ShBPYYqoQFePP-mgBjGezQ2acel0-ReOHF9vCC-G_4Q,232
121
121
  coretrace_python/ir/defuse.py,sha256=CZEPnlgntRp4MqwR7kLldd2ZlIlBnr-V9VJAxXxwm0c,2616
@@ -138,20 +138,20 @@ coretrace_python/reporters/text.py,sha256=bHsHkMheyL0AB1iWtxBO5H9S14k1AqJvKkNsFr
138
138
  coretrace_python/semantic/__init__.py,sha256=tJOvNvMzJ9yjP2bgkLpPO29pl9gMQgICh6XVYJHrovw,464
139
139
  coretrace_python/semantic/identity.py,sha256=uQBdqEXrOtYmGIiR9hZu0Pimv52iW1rOOOvIdG0InNY,1472
140
140
  coretrace_python/semantic/imports.py,sha256=389MZs8iTUc3CyUHG0VAgWFsypd7VPFKkLHmFz9L-N0,5673
141
- coretrace_python/semantic/scopes.py,sha256=zg8ClITSAUbOMvLOV5BsfdG1oC4cd0VweUb9S5Jc4js,20241
141
+ coretrace_python/semantic/scopes.py,sha256=-ZP88ppAjSBUPB5TvsznuP8YmCXU-LzSq6acq8iM95w,20264
142
142
  coretrace_python/semantic/symbols.py,sha256=G0jwsWFo2346Y1R2sztsB8HuZZm_aHfHSJXXeZKPQLo,7354
143
143
  coretrace_python/source/__init__.py,sha256=0Y8fvtbkETnNCJR9Zq7RS5TSgF9c22pz3SSk1KdAXF0,301
144
144
  coretrace_python/source/manager.py,sha256=JRmMOcWfS1nocbVrdcvfQTqEfIkmnb5JP857TpGywN4,2736
145
145
  coretrace_python/source/model.py,sha256=FZnN5mlir3tzbIpWoxnn6YtRi9UcJ_y0sZM60tKr8U4,1727
146
146
  coretrace_python/taint/__init__.py,sha256=nKpD33_4IRneAkd3Dt-j7Q-cHl47rDA_eLZFDHZVH28,1827
147
- coretrace_python/taint/engine.py,sha256=B3_o8feN0-a6PAfZCyERIxMiZnF50nTMi3rP5uesHOM,48923
148
- coretrace_python/taint/models.py,sha256=EMzAMY3woBjgDlGyFi9vsVmuE5r2OjnwEvHHoe9IuWE,20026
147
+ coretrace_python/taint/engine.py,sha256=InunQrFgPq5NmiNkn0BQTTb_7D4IY_wZ2sJZn_K4iMs,51533
148
+ coretrace_python/taint/models.py,sha256=pCvE-6dYKYSLK7pylxhLWpeUcMfMyXlh_1VUJbzieo8,20405
149
149
  coretrace_python/taint/routes.py,sha256=UAJKXcw_sM9shf58FfDroxVO9bNSm4qrQdZknwUB0lo,6110
150
- coretrace_python/taint/templates.py,sha256=cxa2PC-Md0pAPu0NxrIUJ5XMs26zck7O-dDH30TTB2I,31023
150
+ coretrace_python/taint/templates.py,sha256=gUpUvVk_UIVY2a3T5ENm2TfBuPCDhnu2lu8h50srZ4Q,43963
151
151
  coretrace_python/taint/urls.py,sha256=kb0-uOT12ww7R3Jzs_lPKg9lFs74JI-bpa9pXX105xM,3166
152
- coretrace_python_analyzer-0.17.0.dist-info/METADATA,sha256=LCZjkte2jNcPz06rsm7jMwboXtkbiwbyrIQh9Ij4nR8,3217
153
- coretrace_python_analyzer-0.17.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
154
- coretrace_python_analyzer-0.17.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
155
- coretrace_python_analyzer-0.17.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
156
- coretrace_python_analyzer-0.17.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
157
- coretrace_python_analyzer-0.17.0.dist-info/RECORD,,
152
+ coretrace_python_analyzer-0.19.0.dist-info/METADATA,sha256=QWl44X6mXg-aIRsdgMZIGyX-Ms3CvvpysboxggSRJi4,3217
153
+ coretrace_python_analyzer-0.19.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
154
+ coretrace_python_analyzer-0.19.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
155
+ coretrace_python_analyzer-0.19.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
156
+ coretrace_python_analyzer-0.19.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
157
+ coretrace_python_analyzer-0.19.0.dist-info/RECORD,,