coretrace-python-analyzer 0.15.0__py3-none-any.whl → 0.17.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.17.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(
@@ -80,9 +80,12 @@ from coretrace_python.interprocedural import (
80
80
  ModuleFunction,
81
81
  ModuleGraph,
82
82
  ProjectSummaries,
83
+ SignaturesAnalysis,
83
84
  SummaryAnalysis,
84
85
  SummaryIndex,
85
86
  SymbolRead,
87
+ TemplateCalls,
88
+ TemplateCallsAnalysis,
86
89
  build_module_graph,
87
90
  discover_sources,
88
91
  project_symbol,
@@ -124,7 +127,10 @@ from coretrace_python.taint import (
124
127
  TaintAnalysis,
125
128
  TaintKind,
126
129
  escaped_templates,
130
+ project_templates,
127
131
  registered_routes,
132
+ request_processor,
133
+ unread_renders,
128
134
  )
129
135
  from coretrace_python.taint.urls import flow_url
130
136
 
@@ -158,6 +164,8 @@ ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
158
164
  RegisteredRoutes,
159
165
  EscapedTemplates,
160
166
  ClearingAnalysis,
167
+ TemplateCallsAnalysis,
168
+ SignaturesAnalysis,
161
169
  )
162
170
 
163
171
 
@@ -198,6 +206,8 @@ class ResultsEvicted(TransformationPass):
198
206
  RegisteredRoutes,
199
207
  EscapedTemplates,
200
208
  ClearingAnalysis,
209
+ TemplateCallsAnalysis,
210
+ SignaturesAnalysis,
201
211
  }
202
212
  )
203
213
 
@@ -261,6 +271,7 @@ def build_manager(
261
271
  table = (models or SecurityModelRegistry()).freeze()
262
272
  manager.provide(SecurityModelAnalysis, table)
263
273
  manager.provide(MembersAnalysis, table.members_by_class())
274
+ manager.provide(SignaturesAnalysis, table.signatures())
264
275
  manager.provide(ClearingAnalysis, table.clearing())
265
276
  manager.provide(ProjectSummaries, SummaryIndex())
266
277
  manager.provide(RegisteredRoutes, _routes_of(manager))
@@ -316,6 +327,7 @@ def analyze_file(source: SourceFile, plugin_roots: Sequence[Path]) -> FileAnalys
316
327
  table = plugin_models(loaded.plugin for loaded in registry)
317
328
  manager.provide(SecurityModelAnalysis, table)
318
329
  manager.provide(MembersAnalysis, table.members_by_class())
330
+ manager.provide(SignaturesAnalysis, table.signatures())
319
331
  manager.provide(ClearingAnalysis, table.clearing())
320
332
  manager.provide(ProjectSummaries, SummaryIndex())
321
333
  manager.provide(RegisteredRoutes, _routes_of(manager))
@@ -411,6 +423,7 @@ def analyze_project(
411
423
  for manager in managers.values():
412
424
  manager.provide(SecurityModelAnalysis, models)
413
425
  manager.provide(MembersAnalysis, members)
426
+ manager.provide(SignaturesAnalysis, models.signatures())
414
427
  manager.provide(DependencyAnalysis, dependencies)
415
428
 
416
429
  imports: dict[str, ImportTable] = {}
@@ -431,13 +444,20 @@ def analyze_project(
431
444
  for symbol, registered in _routes_of(analysable[name]).items():
432
445
  routes.setdefault(symbol, registered)
433
446
  escaped = escaped_templates(root)
447
+ templates = project_templates(root)
434
448
  clearing = models.clearing(escaped)
449
+ template_calls = models.template_calls(
450
+ templates.filters, request_processor(modules[name] for name in sorted(analysable))
451
+ )
435
452
  for manager in analysable.values():
436
453
  manager.provide(RegisteredRoutes, routes)
437
454
  manager.provide(EscapedTemplates, escaped)
438
455
  manager.provide(ClearingAnalysis, clearing)
456
+ manager.provide(TemplateCallsAnalysis, template_calls)
439
457
 
440
- configuration = _configuration_key(components, plugins, models, advisories, dependencies, routes, escaped)
458
+ configuration = _configuration_key(
459
+ components, plugins, models, advisories, dependencies, routes, escaped, template_calls
460
+ )
441
461
  keys = module_keys(
442
462
  graph,
443
463
  {name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
@@ -479,6 +499,7 @@ def analyze_project(
479
499
  advisory_paths,
480
500
  _encode_routes(routes),
481
501
  tuple(sorted(escaped)),
502
+ template_calls,
482
503
  ),
483
504
  )
484
505
  for component in pending
@@ -516,7 +537,9 @@ def analyze_project(
516
537
  frozenset(),
517
538
  reads={f: tuple(r) for f, r in reads.items()},
518
539
  )
519
- context = ProjectContext(graph, dependencies, advisories, analysable, call_graphs, policy, root, functions)
540
+ context = ProjectContext(
541
+ graph, dependencies, advisories, analysable, call_graphs, policy, root, functions, templates
542
+ )
520
543
  for plugin in all_plugins:
521
544
  if isinstance(plugin, ProjectPlugin):
522
545
  findings.extend(plugin.analyze_project(context))
@@ -526,6 +549,9 @@ def analyze_project(
526
549
  findings = [_sourced(finding, origins) for finding in findings]
527
550
  accepted = tuple(f for f in findings if policy.accepts(f))
528
551
  kept, suppressed = partition(apply_policy(policy, findings), _text_of(sources))
552
+ unread = list(templates.unread)
553
+ for name in sorted(call_graphs):
554
+ unread.extend(unread_renders(name, call_graphs[name], models, templates.names))
529
555
  return ProjectAnalysis(
530
556
  graph,
531
557
  index,
@@ -534,7 +560,7 @@ def analyze_project(
534
560
  MappingProxyType(keys),
535
561
  reused,
536
562
  advisories,
537
- Coverage(tuple(sorted(coverage, key=lambda c: c.path))),
563
+ Coverage(tuple(sorted(coverage, key=lambda c: c.path)), tuple(unread)),
538
564
  suppressed,
539
565
  accepted,
540
566
  components,
@@ -686,6 +712,7 @@ class _Batch:
686
712
  advisory_paths: tuple[Path, ...] = ()
687
713
  routes: tuple[tuple[str, str, str, int], ...] = ()
688
714
  escaped: tuple[str, ...] = ()
715
+ template_calls: TemplateCalls = field(default_factory=TemplateCalls)
689
716
 
690
717
 
691
718
  def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
@@ -709,10 +736,12 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
709
736
  for manager in managers.values():
710
737
  manager.provide(SecurityModelAnalysis, models)
711
738
  manager.provide(MembersAnalysis, members)
739
+ manager.provide(SignaturesAnalysis, models.signatures())
712
740
  manager.provide(DependencyAnalysis, dependencies)
713
741
  manager.provide(RegisteredRoutes, routes)
714
742
  manager.provide(EscapedTemplates, escaped)
715
743
  manager.provide(ClearingAnalysis, models.clearing(escaped))
744
+ manager.provide(TemplateCallsAnalysis, batch.template_calls)
716
745
  module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
717
746
  results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
718
747
  return {name: encode(entry) for name, entry in results.items()}
@@ -741,6 +770,7 @@ def _configuration_key(
741
770
  dependencies: DependencyGraph,
742
771
  routes: Routes | None = None,
743
772
  escaped: frozenset[str] = frozenset(),
773
+ template_calls: TemplateCalls | None = None,
744
774
  ) -> str:
745
775
  """Everything a module's results depend on besides the project sources (§11)."""
746
776
 
@@ -758,6 +788,7 @@ def _configuration_key(
758
788
  repr(dependencies.errors),
759
789
  repr(_encode_routes(routes or {})),
760
790
  repr(sorted(escaped)),
791
+ repr(template_calls),
761
792
  )
762
793
 
763
794
 
@@ -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:
@@ -8,6 +8,8 @@ from coretrace_python.interprocedural.callgraph import (
8
8
  ExternalSymbol,
9
9
  KnownFunction,
10
10
  ModuleFunction,
11
+ Signature,
12
+ SignaturesAnalysis,
11
13
  SymbolRead,
12
14
  Target,
13
15
  UnknownTarget,
@@ -20,6 +22,7 @@ from coretrace_python.interprocedural.modulegraph import (
20
22
  project_symbol,
21
23
  )
22
24
  from coretrace_python.interprocedural.summaries import (
25
+ FILTER_ARGUMENTS,
23
26
  Cleared,
24
27
  Clearing,
25
28
  ClearingAnalysis,
@@ -31,10 +34,14 @@ from coretrace_python.interprocedural.summaries import (
31
34
  SummaryAnalysis,
32
35
  SummaryIndex,
33
36
  SummaryTable,
37
+ TemplateCalls,
38
+ TemplateCallsAnalysis,
39
+ TemplateFilter,
34
40
  cleared_by,
35
41
  )
36
42
 
37
43
  __all__ = [
44
+ "FILTER_ARGUMENTS",
38
45
  "Arguments",
39
46
  "CallGraph",
40
47
  "CallGraphAnalysis",
@@ -51,11 +58,16 @@ __all__ = [
51
58
  "Mutation",
52
59
  "NonlocalWrite",
53
60
  "ProjectSummaries",
61
+ "Signature",
62
+ "SignaturesAnalysis",
54
63
  "SummaryAnalysis",
55
64
  "SummaryIndex",
56
65
  "SummaryTable",
57
66
  "SymbolRead",
58
67
  "Target",
68
+ "TemplateCalls",
69
+ "TemplateCallsAnalysis",
70
+ "TemplateFilter",
59
71
  "UnknownTarget",
60
72
  "build_module_graph",
61
73
  "cleared_by",
@@ -9,6 +9,7 @@ reads, called or not: reading ``request.form`` runs its getter.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import re
12
13
  from collections.abc import Mapping
13
14
  from dataclasses import dataclass, field
14
15
  from types import MappingProxyType
@@ -104,6 +105,30 @@ class SymbolRead:
104
105
  symbol: SymbolId
105
106
 
106
107
 
108
+ @dataclass(frozen=True)
109
+ class Signature:
110
+ """What the parameters of the methods of a class denote when the canonical symbol
111
+ of its base matches ``pattern``, by position after ``self``: a class symbol, or None
112
+ for a parameter denoting none. A method taking another number of parameters has
113
+ none. The engine derives signatures from the security models (§16)."""
114
+
115
+ pattern: str
116
+ parameters: tuple[SymbolId | None, ...]
117
+
118
+ def matches(self, base: SymbolId) -> bool:
119
+ return re.search(self.pattern, base.canonical_name) is not None
120
+
121
+
122
+ class SignaturesAnalysis(Analysis[tuple[Signature, ...]]):
123
+ """The signatures the models declare, provided by the engine; none on its own."""
124
+
125
+ name: ClassVar[str] = "interprocedural.signatures"
126
+
127
+ @classmethod
128
+ def compute(cls, ctx: AnalysisContext) -> tuple[Signature, ...]:
129
+ return ()
130
+
131
+
107
132
  @dataclass(frozen=True)
108
133
  class ModuleFunction:
109
134
  """One function of a module as a project plugin sees it: the name the call graph
@@ -357,6 +382,20 @@ def _annotated(
357
382
  return found
358
383
 
359
384
 
385
+ def _declared(
386
+ function: nodes.Function, ssa: FunctionIR, bases: tuple[SymbolId, ...], signatures: tuple[Signature, ...]
387
+ ) -> dict[Value, SymbolId]:
388
+ """Parameters a signature types by their position, for a method of a class one of
389
+ whose ``bases`` matches the signature and which takes the parameters it lists."""
390
+
391
+ given = function.parameters[1:]
392
+ for signature in signatures:
393
+ if any(signature.matches(base) for base in bases) and len(given) == len(signature.parameters):
394
+ typed = zip(ssa.parameters[1:], signature.parameters, strict=False)
395
+ return {value: symbol for value, symbol in typed if symbol is not None}
396
+ return {}
397
+
398
+
360
399
  def _typed_with_module_classes(
361
400
  function: nodes.Function, ssa: FunctionIR, classes: Mapping[str, frozenset[str]]
362
401
  ) -> dict[Value, str]:
@@ -372,12 +411,15 @@ def _typed_with_module_classes(
372
411
 
373
412
  class CallGraphAnalysis(Analysis[CallGraph]):
374
413
  name: ClassVar[str] = "interprocedural.callgraph"
375
- requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, ScopeAnalysis, SymbolAnalysis})
414
+ requires: ClassVar[frozenset[AnyAnalysis]] = frozenset(
415
+ {SSAAnalysis, ScopeAnalysis, SymbolAnalysis, SignaturesAnalysis}
416
+ )
376
417
 
377
418
  @classmethod
378
419
  def compute(cls, ctx: AnalysisContext) -> CallGraph:
379
420
  scopes = ctx.get(ScopeAnalysis)
380
421
  table = ctx.get(SymbolAnalysis)
422
+ signatures = ctx.get(SignaturesAnalysis)
381
423
  definitions: dict[str, nodes.Function] = {}
382
424
  for function in analyzable_functions(ctx.module):
383
425
  # A property and its setter, or a redefinition, share a qualified name; each
@@ -396,14 +438,15 @@ class CallGraphAnalysis(Analysis[CallGraph]):
396
438
  for statement in ctx.module.body
397
439
  if isinstance(statement, nodes.Class)
398
440
  }
441
+ # Inheritance follows the first base that resolves; a signature may match any.
399
442
  bases: dict[str, SymbolId] = {}
443
+ ancestry: dict[str, tuple[SymbolId, ...]] = {}
400
444
  for statement in ctx.module.body:
401
445
  if isinstance(statement, nodes.Class):
402
- for base_expression in statement.bases:
403
- symbol = table.resolve_expression(scopes.module_scope.id, base_expression)
404
- if symbol is not None:
405
- bases[statement.name] = symbol
406
- break
446
+ resolved = [table.resolve_expression(scopes.module_scope.id, base) for base in statement.bases]
447
+ ancestry[statement.name] = tuple(symbol for symbol in resolved if symbol is not None)
448
+ if ancestry[statement.name]:
449
+ bases[statement.name] = ancestry[statement.name][0]
407
450
  sites: dict[str, tuple[CallSite, ...]] = {}
408
451
  symbols: dict[str, Mapping[Value, SymbolId]] = {}
409
452
  reads: dict[str, tuple[SymbolRead, ...]] = {}
@@ -418,11 +461,13 @@ class CallGraphAnalysis(Analysis[CallGraph]):
418
461
  owner = name.rsplit(".", 1)[0] if "." in name and name.rsplit(".", 1)[0] in classes else None
419
462
  if owner is not None and _is_staticmethod(function):
420
463
  owner = None # a static method has no receiver
464
+ # A signature types parameters by their position; an annotation says more.
465
+ declared = _declared(function, ssa, ancestry.get(owner, ()) if owner is not None else (), signatures)
421
466
  targets, symbols[name] = resolve_targets(
422
467
  ssa,
423
468
  scopes,
424
469
  known,
425
- _annotated(function, ssa, scopes, table),
470
+ {**declared, **_annotated(function, ssa, scopes, table)},
426
471
  frozenset(definitions),
427
472
  classes,
428
473
  owner,