coretrace-python-analyzer 0.14.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.
Files changed (26) hide show
  1. coretrace_python/__init__.py +1 -1
  2. coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +9 -2
  3. coretrace_python/bundled/models/django/django_models.py +4 -1
  4. coretrace_python/bundled/security/open_redirect/open_redirect.py +33 -3
  5. coretrace_python/bundled/security/open_redirect/plugin.toml +1 -1
  6. coretrace_python/bundled/security/ssrf/ssrf.py +14 -29
  7. coretrace_python/dependency/advisories.py +6 -1
  8. coretrace_python/dependency/correlation.py +36 -5
  9. coretrace_python/dependency/graph.py +4 -2
  10. coretrace_python/dependency/vex.py +10 -1
  11. coretrace_python/engine.py +34 -9
  12. coretrace_python/findings/coverage.py +4 -1
  13. coretrace_python/interprocedural/__init__.py +8 -0
  14. coretrace_python/interprocedural/summaries.py +173 -7
  15. coretrace_python/plugins/api.py +7 -4
  16. coretrace_python/taint/__init__.py +16 -1
  17. coretrace_python/taint/engine.py +63 -4
  18. coretrace_python/taint/models.py +20 -2
  19. coretrace_python/taint/templates.py +458 -18
  20. coretrace_python/taint/urls.py +70 -0
  21. {coretrace_python_analyzer-0.14.0.dist-info → coretrace_python_analyzer-0.16.0.dist-info}/METADATA +1 -1
  22. {coretrace_python_analyzer-0.14.0.dist-info → coretrace_python_analyzer-0.16.0.dist-info}/RECORD +26 -25
  23. {coretrace_python_analyzer-0.14.0.dist-info → coretrace_python_analyzer-0.16.0.dist-info}/WHEEL +0 -0
  24. {coretrace_python_analyzer-0.14.0.dist-info → coretrace_python_analyzer-0.16.0.dist-info}/entry_points.txt +0 -0
  25. {coretrace_python_analyzer-0.14.0.dist-info → coretrace_python_analyzer-0.16.0.dist-info}/licenses/LICENSE +0 -0
  26. {coretrace_python_analyzer-0.14.0.dist-info → coretrace_python_analyzer-0.16.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,4 +1,4 @@
1
1
  """CoreTrace's Python static analysis frontend."""
2
2
 
3
- __version__ = "0.14.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.
@@ -1,12 +1,26 @@
1
- """Open redirect: attacker-controlled input reaching a open redirect sink."""
1
+ """Open redirect: attacker-controlled input reaching a open redirect sink.
2
+
3
+ The rule draws its verdict from what the target's constant text proves (``taint.urls``),
4
+ as a browser resolves it. A reference on the current site (``/profile/``, ``?page=``,
5
+ ``profile/``) keeps the browser on the site, where any further redirect is the project's
6
+ own code, analysed on its own: refuted. A fixed absolute host sends the browser there,
7
+ but the input chooses the path, where that host may redirect again: a hotspot. Anything
8
+ unproven, ``"/" + next`` included, stays a vulnerability.
9
+ """
2
10
 
3
11
  from __future__ import annotations
4
12
 
5
13
  from typing import ClassVar
6
14
 
15
+ from coretrace_python.abstract.strings import ModuleStringsAnalysis
16
+ from coretrace_python.analysis import AnyAnalysis
7
17
  from coretrace_python.findings import Severity
8
- from coretrace_python.plugins import TaintDetector
9
- from coretrace_python.taint import TaintKind
18
+ from coretrace_python.findings.refutation import Status, Verdict
19
+ from coretrace_python.hir import nodes
20
+ from coretrace_python.ir.ssa import SSAAnalysis
21
+ from coretrace_python.plugins import PluginContext, TaintDetector
22
+ from coretrace_python.taint import TaintFlow, TaintKind
23
+ from coretrace_python.taint.urls import flow_url
10
24
 
11
25
 
12
26
  class OpenRedirectPlugin(TaintDetector):
@@ -15,3 +29,19 @@ class OpenRedirectPlugin(TaintDetector):
15
29
  kind: ClassVar[TaintKind] = TaintKind.REDIRECT
16
30
  severity: ClassVar[Severity] = Severity.MEDIUM
17
31
  title: ClassVar[str] = "Open redirect"
32
+ requires: ClassVar[frozenset[AnyAnalysis]] = TaintDetector.requires | {SSAAnalysis, ModuleStringsAnalysis}
33
+
34
+ def judge(self, ctx: PluginContext, function: nodes.Function, flow: TaintFlow, verdict: Verdict) -> Verdict:
35
+ ssa = ctx.get(SSAAnalysis, function)
36
+ defs = {i.result: i for block in ssa.blocks for i in block.instructions if i.result is not None}
37
+ target = flow_url(flow, defs, ctx.get(ModuleStringsAnalysis))
38
+ if target.relative:
39
+ return Verdict(flow, Status.REFUTED, f"the browser stays on the site: the target starts with {target.text!r}")
40
+ if target.origin is not None and verdict.status is Status.VULNERABILITY:
41
+ return Verdict(
42
+ flow,
43
+ Status.HOTSPOT,
44
+ f"the browser goes to the host fixed by {target.origin!r}, but the input chooses the path, "
45
+ "where that host may redirect again",
46
+ )
47
+ return verdict
@@ -1,7 +1,7 @@
1
1
  name = "open-redirect"
2
2
  version = "1.0.0"
3
3
  plugin_api = ">=1,<2"
4
- requires = ["taint.flows", "findings.refutation"]
4
+ requires = ["taint.flows", "findings.refutation", "ir.ssa", "abstract.module_strings"]
5
5
  provides = ["vulnerability.open-redirect"]
6
6
 
7
7
  [entrypoint]
@@ -1,21 +1,18 @@
1
1
  """Server-side request forgery: attacker-controlled input reaching a SSRF sink.
2
2
 
3
- How the URL is built, with ``+`` and f-strings, can prove part of its destination. Constant
4
- text fixing ``scheme://host`` and ending the authority with ``/``, ``?`` or ``#`` before
5
- any input keeps the input from choosing the host; the input still chooses the path, which
6
- may reach a sensitive resource of that host, and a redirect may lead elsewhere, so the
7
- flow is a hotspot. With the path fixed too, the input reaching the query or fragment
8
- only, and redirects disabled in the call, the destination is fixed and the flow is
9
- refuted. Anything unproven, a base of unknown value or a URL built in a helper function,
10
- stays a vulnerability.
3
+ The rule draws its verdict from what the URL's constant text proves (``taint.urls``).
4
+ A fixed host keeps the input from choosing it, but the input still chooses the path,
5
+ which may reach a sensitive resource of that host, and a redirect may lead elsewhere: a
6
+ hotspot. With the path fixed too, the input reaching the query or fragment only, and
7
+ redirects disabled in the call, the destination is fixed: refuted. Anything unproven,
8
+ a base of unknown value or a URL built in a helper function, stays a vulnerability.
11
9
  """
12
10
 
13
11
  from __future__ import annotations
14
12
 
15
- import re
16
13
  from typing import ClassVar
17
14
 
18
- from coretrace_python.abstract.strings import ModuleStringsAnalysis, leading_text
15
+ from coretrace_python.abstract.strings import ModuleStringsAnalysis
19
16
  from coretrace_python.analysis import AnyAnalysis
20
17
  from coretrace_python.findings import Severity
21
18
  from coretrace_python.findings.refutation import Status, Verdict
@@ -23,15 +20,7 @@ from coretrace_python.hir import nodes
23
20
  from coretrace_python.ir.ssa import SSAAnalysis
24
21
  from coretrace_python.plugins import PluginContext, TaintDetector
25
22
  from coretrace_python.taint import TaintFlow, TaintKind
26
-
27
- # ``scheme://`` and an authority ended by ``/``, ``?`` or ``#``. A backslash, which some
28
- # URL parsers take for a slash, does not end it here.
29
- _FIXED_HOST = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^/?#\\]+[/?#]")
30
- # The same with the path fixed too: what follows is query or fragment.
31
- _FIXED_PATH = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*://[^/?#\\]+(?:/[^?#]*)?[?#]")
32
- # A client honours the one of these keywords it knows and rejects the other with a
33
- # ``TypeError``: either way, the call follows no redirect.
34
- _NO_REDIRECTS = ("allow_redirects", "follow_redirects")
23
+ from coretrace_python.taint.urls import flow_url, redirects_disabled
35
24
 
36
25
 
37
26
  class SsrfPlugin(TaintDetector):
@@ -43,22 +32,18 @@ class SsrfPlugin(TaintDetector):
43
32
  requires: ClassVar[frozenset[AnyAnalysis]] = TaintDetector.requires | {SSAAnalysis, ModuleStringsAnalysis}
44
33
 
45
34
  def judge(self, ctx: PluginContext, function: nodes.Function, flow: TaintFlow, verdict: Verdict) -> Verdict:
46
- if flow.through is not None:
47
- return verdict # the URL is built in the callee
48
35
  ssa = ctx.get(SSAAnalysis, function)
49
36
  defs = {i.result: i for block in ssa.blocks for i in block.instructions if i.result is not None}
50
- text, _ = leading_text(flow.argument, defs, ctx.get(ModuleStringsAnalysis))
51
- host = _FIXED_HOST.match(text)
52
- if host is None:
37
+ url = flow_url(flow, defs, ctx.get(ModuleStringsAnalysis))
38
+ if url.origin is None:
53
39
  return verdict
54
- arguments = flow.sink_arguments
55
- no_redirects = arguments is not None and any(arguments.given(name) == (True, "False") for name in _NO_REDIRECTS)
56
- if _FIXED_PATH.match(text) is not None:
40
+ no_redirects = redirects_disabled(flow.sink_arguments)
41
+ if url.path_fixed:
57
42
  if no_redirects:
58
- return Verdict(flow, Status.REFUTED, f"the destination is fixed by {text!r} and redirects are disabled")
43
+ return Verdict(flow, Status.REFUTED, f"the destination is fixed by {url.text!r} and redirects are disabled")
59
44
  left = "a redirect may lead elsewhere"
60
45
  else:
61
46
  left = "the input chooses the path" + ("" if no_redirects else ", and a redirect may lead elsewhere")
62
47
  if verdict.status is not Status.VULNERABILITY:
63
48
  return verdict
64
- return Verdict(flow, Status.HOTSPOT, f"the host is fixed by {host.group(0)!r}, but {left}")
49
+ return Verdict(flow, Status.HOTSPOT, f"the host is fixed by {url.origin!r}, but {left}")
@@ -251,7 +251,7 @@ def _condition(entry: Mapping[str, Any]) -> Condition:
251
251
  default = entry.get("default", False)
252
252
  if not isinstance(default, bool):
253
253
  raise TypeError(f"condition default must be true or false, got {default!r}")
254
- return Condition(
254
+ condition = Condition(
255
255
  str(entry["kind"]),
256
256
  str(entry["text"]),
257
257
  None if entry.get("argument") is None else str(entry["argument"]),
@@ -259,3 +259,8 @@ def _condition(entry: Mapping[str, Any]) -> Condition:
259
259
  position,
260
260
  default,
261
261
  )
262
+ if condition.kind == "host" and (
263
+ (condition.argument is None and condition.position is None) or condition.values or condition.default
264
+ ):
265
+ raise ValueError("a host condition names the URL argument, by keyword or position, and nothing else")
266
+ return condition
@@ -10,7 +10,7 @@ are correlated here into one high-confidence ``exploitable-vulnerability`` findi
10
10
  from __future__ import annotations
11
11
 
12
12
  from collections.abc import Iterable, Mapping
13
- from dataclasses import dataclass
13
+ from dataclasses import dataclass, replace
14
14
 
15
15
  from coretrace_python.dependency.graph import (
16
16
  DIRECT,
@@ -24,6 +24,7 @@ from coretrace_python.findings.refutation import Status, Verdict, Verdicts
24
24
  from coretrace_python.interprocedural import Arguments, CallSite, ExternalSymbol
25
25
  from coretrace_python.semantic.symbols import SymbolId
26
26
  from coretrace_python.taint import Sink, TaintFlow, TaintKind
27
+ from coretrace_python.taint.urls import UrlProof, redirects_disabled
27
28
 
28
29
  Affected = Mapping[SymbolId, tuple[Advisory, ...]]
29
30
 
@@ -61,14 +62,33 @@ class ConditionCheck:
61
62
  passed: str | None = None
62
63
 
63
64
 
64
- def check_conditions(entry: AdvisoryEntryPoint | None, arguments: Arguments | None) -> ConditionCheck:
65
- """Decide ``entry``'s conditions against what the call's ``arguments`` denote."""
65
+ def check_conditions(
66
+ entry: AdvisoryEntryPoint | None,
67
+ arguments: Arguments | None,
68
+ url: UrlProof | None = None,
69
+ passed_as: frozenset[tuple[int | None, str | None]] = frozenset(),
70
+ ) -> ConditionCheck:
71
+ """Decide ``entry``'s conditions against what the call's ``arguments`` denote and, for
72
+ a ``host`` condition, what ``url`` proves of the value the attacker's input is passed
73
+ in (``passed_as``). A host the URL's constant text fixes rules the call out when the
74
+ call disables redirects; otherwise a redirect may still lead to a host the attacker
75
+ controls, and the condition stays pending with that uncertainty."""
66
76
 
67
77
  if entry is None:
68
78
  return ConditionCheck()
69
79
  met: list[Condition] = []
70
80
  pending: list[Condition] = []
71
81
  for condition in entry.conditions:
82
+ if condition.kind == "host":
83
+ origin = url.origin if url is not None and _passed_in(condition, passed_as) else None
84
+ if origin is None:
85
+ pending.append(condition)
86
+ elif redirects_disabled(arguments):
87
+ return ConditionCheck(tuple(met), tuple(pending), condition, repr(origin))
88
+ else:
89
+ text = f"the host is fixed by {origin!r}, but a redirect may lead to a host the attacker controls"
90
+ pending.append(replace(condition, text=text))
91
+ continue
72
92
  given = arguments.given(condition.argument, condition.position) if condition.checkable and arguments is not None else None
73
93
  if given is None:
74
94
  pending.append(condition)
@@ -84,6 +104,16 @@ def check_conditions(entry: AdvisoryEntryPoint | None, arguments: Arguments | No
84
104
  return ConditionCheck(tuple(met), tuple(pending))
85
105
 
86
106
 
107
+ def _passed_in(condition: Condition, passed_as: frozenset[tuple[int | None, str | None]]) -> bool:
108
+ """Whether a value passed as ``passed_as`` is the argument ``condition`` names."""
109
+
110
+ return any(
111
+ (keyword is not None and keyword == condition.argument)
112
+ or (keyword is None and position is not None and position == condition.position)
113
+ for position, keyword in passed_as
114
+ )
115
+
116
+
87
117
  def ruled_out(module: str, site: CallSite, check: ConditionCheck) -> str:
88
118
  """Which call a contradicted condition rules out, and why: ``app:12
89
119
  python.yaml.load(Loader=python.yaml.SafeLoader)``."""
@@ -120,9 +150,10 @@ def correlate(
120
150
  flows: Iterable[TaintFlow],
121
151
  verdicts: Verdicts | None,
122
152
  affected: Affected,
153
+ urls: Mapping[TaintFlow, UrlProof] | None = None,
123
154
  ) -> tuple[Finding, ...]:
124
155
  """Exploitable-vulnerability findings for the non-refuted ADVISORY flows of a function,
125
- one per advisory the sink is affected by."""
156
+ one per advisory the sink is affected by; ``urls`` holds what each flow's URL proves."""
126
157
 
127
158
  findings: list[Finding] = []
128
159
  for flow in flows:
@@ -136,7 +167,7 @@ def correlate(
136
167
  entry = advisory.entry_point(flow.sink.symbol)
137
168
  if entry is not None and not any(entry.exploitable_through(p, k) for p, k in flow.passed_as):
138
169
  continue
139
- check = check_conditions(entry, flow.sink_arguments)
170
+ check = check_conditions(entry, flow.sink_arguments, (urls or {}).get(flow), flow.passed_as)
140
171
  if check.contradicted is None:
141
172
  findings.append(_exploitable(function, flow, advisory, verdict, hotspot, check))
142
173
  return tuple(findings)
@@ -151,8 +151,10 @@ class Condition:
151
151
  ``argument`` condition names the argument by keyword, and by ``position`` when it may
152
152
  be passed positionally, and lists the ``values`` that satisfy it — symbols
153
153
  (``python.yaml.FullLoader``) or constants as Python writes them (``True``);
154
- ``default`` says an absent argument means a vulnerable value. A ``semantic``
155
- condition cannot be checked and is reported as pending review."""
154
+ ``default`` says an absent argument means a vulnerable value. A ``host`` condition
155
+ says the attacker must choose the host of the URL passed as ``argument`` (or at
156
+ ``position``); the engine decides it from what the URL's constant text proves. A
157
+ ``semantic`` condition cannot be checked and is reported as pending review."""
156
158
 
157
159
  kind: str
158
160
  text: str
@@ -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,8 +126,12 @@ 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
  )
134
+ from coretrace_python.taint.urls import flow_url
129
135
 
130
136
  TOOL_NAME = "coretrace-python-analyzer"
131
137
 
@@ -157,6 +163,7 @@ ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
157
163
  RegisteredRoutes,
158
164
  EscapedTemplates,
159
165
  ClearingAnalysis,
166
+ TemplateCallsAnalysis,
160
167
  )
161
168
 
162
169
 
@@ -197,6 +204,7 @@ class ResultsEvicted(TransformationPass):
197
204
  RegisteredRoutes,
198
205
  EscapedTemplates,
199
206
  ClearingAnalysis,
207
+ TemplateCallsAnalysis,
200
208
  }
201
209
  )
202
210
 
@@ -430,13 +438,20 @@ def analyze_project(
430
438
  for symbol, registered in _routes_of(analysable[name]).items():
431
439
  routes.setdefault(symbol, registered)
432
440
  escaped = escaped_templates(root)
441
+ templates = project_templates(root)
433
442
  clearing = models.clearing(escaped)
443
+ template_calls = models.template_calls(
444
+ templates.filters, request_processor(modules[name] for name in sorted(analysable))
445
+ )
434
446
  for manager in analysable.values():
435
447
  manager.provide(RegisteredRoutes, routes)
436
448
  manager.provide(EscapedTemplates, escaped)
437
449
  manager.provide(ClearingAnalysis, clearing)
450
+ manager.provide(TemplateCallsAnalysis, template_calls)
438
451
 
439
- 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
+ )
440
455
  keys = module_keys(
441
456
  graph,
442
457
  {name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
@@ -478,6 +493,7 @@ def analyze_project(
478
493
  advisory_paths,
479
494
  _encode_routes(routes),
480
495
  tuple(sorted(escaped)),
496
+ template_calls,
481
497
  ),
482
498
  )
483
499
  for component in pending
@@ -515,7 +531,9 @@ def analyze_project(
515
531
  frozenset(),
516
532
  reads={f: tuple(r) for f, r in reads.items()},
517
533
  )
518
- 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
+ )
519
537
  for plugin in all_plugins:
520
538
  if isinstance(plugin, ProjectPlugin):
521
539
  findings.extend(plugin.analyze_project(context))
@@ -525,6 +543,9 @@ def analyze_project(
525
543
  findings = [_sourced(finding, origins) for finding in findings]
526
544
  accepted = tuple(f for f in findings if policy.accepts(f))
527
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))
528
549
  return ProjectAnalysis(
529
550
  graph,
530
551
  index,
@@ -533,7 +554,7 @@ def analyze_project(
533
554
  MappingProxyType(keys),
534
555
  reused,
535
556
  advisories,
536
- Coverage(tuple(sorted(coverage, key=lambda c: c.path))),
557
+ Coverage(tuple(sorted(coverage, key=lambda c: c.path)), tuple(unread)),
537
558
  suppressed,
538
559
  accepted,
539
560
  components,
@@ -685,6 +706,7 @@ class _Batch:
685
706
  advisory_paths: tuple[Path, ...] = ()
686
707
  routes: tuple[tuple[str, str, str, int], ...] = ()
687
708
  escaped: tuple[str, ...] = ()
709
+ template_calls: TemplateCalls = field(default_factory=TemplateCalls)
688
710
 
689
711
 
690
712
  def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
@@ -712,6 +734,7 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
712
734
  manager.provide(RegisteredRoutes, routes)
713
735
  manager.provide(EscapedTemplates, escaped)
714
736
  manager.provide(ClearingAnalysis, models.clearing(escaped))
737
+ manager.provide(TemplateCallsAnalysis, batch.template_calls)
715
738
  module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
716
739
  results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
717
740
  return {name: encode(entry) for name, entry in results.items()}
@@ -740,6 +763,7 @@ def _configuration_key(
740
763
  dependencies: DependencyGraph,
741
764
  routes: Routes | None = None,
742
765
  escaped: frozenset[str] = frozenset(),
766
+ template_calls: TemplateCalls | None = None,
743
767
  ) -> str:
744
768
  """Everything a module's results depend on besides the project sources (§11)."""
745
769
 
@@ -757,6 +781,7 @@ def _configuration_key(
757
781
  repr(dependencies.errors),
758
782
  repr(_encode_routes(routes or {})),
759
783
  repr(sorted(escaped)),
784
+ repr(template_calls),
760
785
  )
761
786
 
762
787
 
@@ -770,14 +795,14 @@ def _analyse_module(
770
795
  graph = manager.get(CallGraphAnalysis)
771
796
  correlated: list[Finding] = []
772
797
  if affected:
798
+ strings = manager.get(ModuleStringsAnalysis)
773
799
  for function in supported:
800
+ flows = manager.get(TaintAnalysis, function).flows
801
+ ssa = manager.get(SSAAnalysis, function)
802
+ defs = {i.result: i for block in ssa.blocks for i in block.instructions if i.result is not None}
803
+ urls = {flow: flow_url(flow, defs, strings) for flow in flows if flow.kinds & TaintKind.ADVISORY}
774
804
  correlated.extend(
775
- correlate(
776
- graph.name_of(function),
777
- manager.get(TaintAnalysis, function).flows,
778
- manager.get(RefutationAnalysis, function),
779
- affected,
780
- )
805
+ correlate(graph.name_of(function), flows, manager.get(RefutationAnalysis, function), affected, urls)
781
806
  )
782
807
  return CachedModule(
783
808
  manager.get(EntryPointAnalysis),
@@ -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",