coretrace-python-analyzer 0.6.0__py3-none-any.whl → 0.7.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.
- coretrace_python/__init__.py +1 -1
- coretrace_python/bundled/models/django/django_models.py +9 -0
- coretrace_python/bundled/models/http_clients/http_client_models.py +22 -13
- coretrace_python/cache.py +3 -3
- coretrace_python/engine.py +19 -2
- coretrace_python/findings/refutation.py +70 -5
- coretrace_python/interprocedural/summaries.py +18 -8
- coretrace_python/taint/__init__.py +5 -0
- coretrace_python/taint/engine.py +34 -7
- coretrace_python/taint/models.py +49 -15
- coretrace_python/taint/templates.py +250 -0
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/METADATA +1 -1
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/RECORD +17 -16
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/WHEEL +0 -0
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/entry_points.txt +0 -0
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/licenses/NOTICE +0 -0
coretrace_python/__init__.py
CHANGED
|
@@ -23,6 +23,7 @@ from coretrace_python.taint import (
|
|
|
23
23
|
Source,
|
|
24
24
|
SuffixSink,
|
|
25
25
|
TaintKind,
|
|
26
|
+
TemplateRender,
|
|
26
27
|
TypedParameter,
|
|
27
28
|
)
|
|
28
29
|
|
|
@@ -119,7 +120,15 @@ class DjangoModels(ModelPlugin):
|
|
|
119
120
|
Sink(_sym("django.http.HttpResponseRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
|
|
120
121
|
Sink(_sym("django.http.HttpResponsePermanentRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
|
|
121
122
|
Sanitizer(_sym("django.utils.html.escape"), TaintKind.HTML),
|
|
123
|
+
TemplateRender(_sym("django.template.loader.render_to_string")),
|
|
122
124
|
Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML),
|
|
125
|
+
# The masked CSRF secret: ASCII letters and digits only, a malformed cookie is
|
|
126
|
+
# replaced before it is used.
|
|
127
|
+
Sanitizer(_sym("django.middleware.csrf.get_token"), TaintKind.ALL),
|
|
128
|
+
# A path of the project's URL configuration, never scheme-relative: attacker data
|
|
129
|
+
# in its arguments or query reaches no other host through a redirect.
|
|
130
|
+
*(Sanitizer(_sym(f"django.urls.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")),
|
|
131
|
+
*(Sanitizer(_sym(f"django.urls.base.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")),
|
|
123
132
|
*(AuthorizationGuard(_sym(decorator), label) for decorator, label in _AUTHORIZATION_DECORATORS),
|
|
124
133
|
# ``urlpatterns = [path('login/', views.log_in)]``: the referenced view is an
|
|
125
134
|
# entry point wherever it is defined; routers register viewsets.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
"""Requests and httpx models: every request function is a SSRF sink
|
|
2
|
-
is an ``http-response`` source, so data
|
|
1
|
+
"""Requests and httpx models: every request function is a SSRF sink for the argument that
|
|
2
|
+
chooses the destination, and what it returns is an ``http-response`` source, so data
|
|
3
|
+
fetched from a remote server is untrusted. The destination is the URL — first, or second
|
|
4
|
+
after the method, or ``url=`` — or the prepared request a ``send`` takes; the body, the
|
|
5
|
+
headers and the query parameters do not choose the host."""
|
|
3
6
|
|
|
4
7
|
from __future__ import annotations
|
|
5
8
|
|
|
@@ -20,15 +23,18 @@ _CALLERS = (
|
|
|
20
23
|
"httpx.AsyncClient",
|
|
21
24
|
)
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"httpx.
|
|
29
|
-
"httpx.Client.
|
|
30
|
-
"httpx.AsyncClient.
|
|
31
|
-
)
|
|
26
|
+
# Where each function takes its destination, by position (the receiver of a method
|
|
27
|
+
# excluded) and by keyword, as its signature declares it.
|
|
28
|
+
_DESTINATIONS = {
|
|
29
|
+
**{f"{caller}.{method}": (0, "url") for caller in _CALLERS for method in _METHODS if method != "request"},
|
|
30
|
+
**{f"{caller}.request": (1, "url") for caller in _CALLERS},
|
|
31
|
+
"httpx.stream": (1, "url"),
|
|
32
|
+
"httpx.Client.stream": (1, "url"),
|
|
33
|
+
"httpx.AsyncClient.stream": (1, "url"),
|
|
34
|
+
"requests.Session.send": (0, "request"),
|
|
35
|
+
"httpx.Client.send": (0, "request"),
|
|
36
|
+
"httpx.AsyncClient.send": (0, "request"),
|
|
37
|
+
}
|
|
32
38
|
|
|
33
39
|
|
|
34
40
|
def _sym(path: str) -> SymbolId:
|
|
@@ -38,6 +44,9 @@ def _sym(path: str) -> SymbolId:
|
|
|
38
44
|
class HttpClientModels(ModelPlugin):
|
|
39
45
|
name: ClassVar[str] = "http-client-models"
|
|
40
46
|
models: ClassVar[tuple[Model, ...]] = (
|
|
41
|
-
*(
|
|
42
|
-
|
|
47
|
+
*(
|
|
48
|
+
Sink(_sym(function), TaintKind.SSRF, ((TaintKind.SSRF, (position,)),), ((TaintKind.SSRF, (keyword,)),))
|
|
49
|
+
for function, (position, keyword) in _DESTINATIONS.items()
|
|
50
|
+
),
|
|
51
|
+
*(Source(_sym(function), "http-response") for function in _DESTINATIONS),
|
|
43
52
|
)
|
coretrace_python/cache.py
CHANGED
|
@@ -41,7 +41,7 @@ from coretrace_python.interprocedural import (
|
|
|
41
41
|
from coretrace_python.semantic.symbols import SymbolId
|
|
42
42
|
from coretrace_python.source import SourceId, SourceSpan
|
|
43
43
|
|
|
44
|
-
CACHE_FORMAT =
|
|
44
|
+
CACHE_FORMAT = 9
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
@dataclass(frozen=True)
|
|
@@ -233,7 +233,7 @@ def _encode_call(call: ExternalCall) -> dict[str, Any]:
|
|
|
233
233
|
return {
|
|
234
234
|
"symbol": str(call.symbol),
|
|
235
235
|
"arguments": [sorted(deps) for deps in call.argument_dependencies],
|
|
236
|
-
"keywords": sorted(call.keyword_dependencies
|
|
236
|
+
"keywords": [[name, sorted(deps)] for name, deps in call.keyword_dependencies],
|
|
237
237
|
"location": _encode_span(call.location),
|
|
238
238
|
"call_site": None if call.call_site is None else _encode_span(call.call_site),
|
|
239
239
|
"given": _encode_arguments(call.arguments),
|
|
@@ -245,7 +245,7 @@ def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
|
|
|
245
245
|
return ExternalCall(
|
|
246
246
|
SymbolId(_string(data["symbol"])),
|
|
247
247
|
tuple(_indices(deps) for deps in data["arguments"]),
|
|
248
|
-
_indices(data["keywords"]),
|
|
248
|
+
tuple((None if name is None else _string(name), _indices(deps)) for name, deps in data["keywords"]),
|
|
249
249
|
_decode_span(data["location"]),
|
|
250
250
|
None if site is None else _decode_span(site),
|
|
251
251
|
_decode_arguments(data["given"]),
|
coretrace_python/engine.py
CHANGED
|
@@ -109,6 +109,7 @@ from coretrace_python.source import SourceFile, SourceId, SourceManager, SourceS
|
|
|
109
109
|
from coretrace_python.taint import (
|
|
110
110
|
EntryPoint,
|
|
111
111
|
EntryPointAnalysis,
|
|
112
|
+
EscapedTemplates,
|
|
112
113
|
ModelTable,
|
|
113
114
|
RegisteredRoutes,
|
|
114
115
|
Routes,
|
|
@@ -116,6 +117,7 @@ from coretrace_python.taint import (
|
|
|
116
117
|
SecurityModelRegistry,
|
|
117
118
|
TaintAnalysis,
|
|
118
119
|
TaintKind,
|
|
120
|
+
escaped_templates,
|
|
119
121
|
registered_routes,
|
|
120
122
|
)
|
|
121
123
|
|
|
@@ -146,6 +148,7 @@ ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
|
|
|
146
148
|
RefutationAnalysis,
|
|
147
149
|
DependencyAnalysis,
|
|
148
150
|
RegisteredRoutes,
|
|
151
|
+
EscapedTemplates,
|
|
149
152
|
)
|
|
150
153
|
|
|
151
154
|
|
|
@@ -178,7 +181,14 @@ class ResultsEvicted(TransformationPass):
|
|
|
178
181
|
|
|
179
182
|
name: ClassVar[str] = "project.results-evicted"
|
|
180
183
|
preserves: ClassVar[frozenset[AnyAnalysis]] = frozenset(
|
|
181
|
-
{
|
|
184
|
+
{
|
|
185
|
+
*SEMANTIC_ANALYSES,
|
|
186
|
+
SecurityModelAnalysis,
|
|
187
|
+
ProjectSummaries,
|
|
188
|
+
DependencyAnalysis,
|
|
189
|
+
RegisteredRoutes,
|
|
190
|
+
EscapedTemplates,
|
|
191
|
+
}
|
|
182
192
|
)
|
|
183
193
|
|
|
184
194
|
@classmethod
|
|
@@ -397,10 +407,12 @@ def analyze_project(
|
|
|
397
407
|
for name in sorted(analysable):
|
|
398
408
|
for symbol, registered in _routes_of(analysable[name]).items():
|
|
399
409
|
routes.setdefault(symbol, registered)
|
|
410
|
+
escaped = escaped_templates(root)
|
|
400
411
|
for manager in analysable.values():
|
|
401
412
|
manager.provide(RegisteredRoutes, routes)
|
|
413
|
+
manager.provide(EscapedTemplates, escaped)
|
|
402
414
|
|
|
403
|
-
configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes)
|
|
415
|
+
configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes, escaped)
|
|
404
416
|
keys = module_keys(
|
|
405
417
|
graph,
|
|
406
418
|
{name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
|
|
@@ -441,6 +453,7 @@ def analyze_project(
|
|
|
441
453
|
encode_index(_seed(results, graph, component)),
|
|
442
454
|
advisory_paths,
|
|
443
455
|
_encode_routes(routes),
|
|
456
|
+
tuple(sorted(escaped)),
|
|
444
457
|
),
|
|
445
458
|
)
|
|
446
459
|
for component in pending
|
|
@@ -588,6 +601,7 @@ class _Batch:
|
|
|
588
601
|
seed: Mapping[str, Any]
|
|
589
602
|
advisory_paths: tuple[Path, ...] = ()
|
|
590
603
|
routes: tuple[tuple[str, str, str, int], ...] = ()
|
|
604
|
+
escaped: tuple[str, ...] = ()
|
|
591
605
|
|
|
592
606
|
|
|
593
607
|
def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
|
|
@@ -612,6 +626,7 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
|
|
|
612
626
|
manager.provide(SecurityModelAnalysis, models)
|
|
613
627
|
manager.provide(DependencyAnalysis, dependencies)
|
|
614
628
|
manager.provide(RegisteredRoutes, routes)
|
|
629
|
+
manager.provide(EscapedTemplates, frozenset(batch.escaped))
|
|
615
630
|
module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
|
|
616
631
|
results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
|
|
617
632
|
return {name: encode(entry) for name, entry in results.items()}
|
|
@@ -639,6 +654,7 @@ def _configuration_key(
|
|
|
639
654
|
advisories: tuple[Advisory, ...],
|
|
640
655
|
dependencies: DependencyGraph,
|
|
641
656
|
routes: Routes | None = None,
|
|
657
|
+
escaped: frozenset[str] = frozenset(),
|
|
642
658
|
) -> str:
|
|
643
659
|
"""Everything a module's results depend on besides the project sources (§11)."""
|
|
644
660
|
|
|
@@ -658,6 +674,7 @@ def _configuration_key(
|
|
|
658
674
|
repr(dependencies.requirements),
|
|
659
675
|
repr(dependencies.errors),
|
|
660
676
|
repr(_encode_routes(routes or {})),
|
|
677
|
+
repr(sorted(escaped)),
|
|
661
678
|
)
|
|
662
679
|
|
|
663
680
|
|
|
@@ -8,10 +8,13 @@ and ``not`` combine as expected, a ``Validator`` model names a callable whose tr
|
|
|
8
8
|
one of its arguments, a numeric value (``abstract.ranges``) cannot inject, and anything
|
|
9
9
|
else that mentions the value is a guard that does not prove it. A proof counts for an
|
|
10
10
|
origin when every dependence path from that origin to the sink argument goes through a
|
|
11
|
-
proven value. A
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
proven value. A guard mentions an origin only when it examines what the sink receives:
|
|
12
|
+
reading another attribute or item of an object the flow reads (``request.method`` next
|
|
13
|
+
to ``request.POST``) examines nothing of it, while the same attribute, or a method called
|
|
14
|
+
on the object (``form.is_valid()``), does. A flow is refuted when every tainted origin is
|
|
15
|
+
proven safe or the sink is unreachable, a hotspot when it sits behind an
|
|
16
|
+
``AuthorizationGuard`` (a decorator or a dominating condition) or when an unproven guard
|
|
17
|
+
mentions it, and a vulnerability otherwise.
|
|
15
18
|
"""
|
|
16
19
|
|
|
17
20
|
from __future__ import annotations
|
|
@@ -37,6 +40,7 @@ from coretrace_python.ir.model import (
|
|
|
37
40
|
Constant,
|
|
38
41
|
FunctionIR,
|
|
39
42
|
GetAttr,
|
|
43
|
+
GetItem,
|
|
40
44
|
Instruction,
|
|
41
45
|
UnaryOp,
|
|
42
46
|
Value,
|
|
@@ -267,6 +271,66 @@ class _Judge:
|
|
|
267
271
|
return tested, "equals a constant", definition.operator == "eq"
|
|
268
272
|
return None
|
|
269
273
|
|
|
274
|
+
def examined(
|
|
275
|
+
self, condition: Value, mentioned: set[Value], argument: Value, chain: frozenset[Value]
|
|
276
|
+
) -> set[Value]:
|
|
277
|
+
"""What the guard examines of the values it mentions: everything its condition is
|
|
278
|
+
computed from, except an object it only reads another attribute or item of than
|
|
279
|
+
the flow reads (``request`` in ``request.method`` when the sink receives
|
|
280
|
+
``request.POST['cmd']``). A method called on the object (``form.is_valid()``), the
|
|
281
|
+
same attribute or item, or any part of an object the flow takes whole (passes to
|
|
282
|
+
the sink or to a call), examines it."""
|
|
283
|
+
|
|
284
|
+
reads = {self.access(value) for value in chain} - {None}
|
|
285
|
+
whole = {argument} | {
|
|
286
|
+
operand
|
|
287
|
+
for value in chain
|
|
288
|
+
if (definition := self.defs.get(value)) is not None
|
|
289
|
+
for operand in definition.operands()
|
|
290
|
+
if not (isinstance(definition, GetAttr | GetItem) and definition.object == operand)
|
|
291
|
+
}
|
|
292
|
+
found: set[Value] = set()
|
|
293
|
+
called: set[Value] = set()
|
|
294
|
+
pending = [condition]
|
|
295
|
+
while pending:
|
|
296
|
+
value = pending.pop()
|
|
297
|
+
if value in found:
|
|
298
|
+
continue
|
|
299
|
+
found.add(value)
|
|
300
|
+
definition = self.defs.get(value)
|
|
301
|
+
if definition is None:
|
|
302
|
+
continue
|
|
303
|
+
if isinstance(definition, Call):
|
|
304
|
+
called.add(definition.callee)
|
|
305
|
+
access = None if value in called else self.access(value)
|
|
306
|
+
for operand in definition.operands():
|
|
307
|
+
if access is not None and access[0] == operand and operand not in whole and self.aside(access, reads):
|
|
308
|
+
continue
|
|
309
|
+
pending.append(operand)
|
|
310
|
+
return found & mentioned
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def aside(access: tuple[Value, str, object], reads: set[tuple[Value, str, object] | None]) -> bool:
|
|
314
|
+
"""Whether reading ``access`` reads beside what the flow reads of the same object;
|
|
315
|
+
an item under a key unknown on either side may be the same one."""
|
|
316
|
+
|
|
317
|
+
owner, kind, name = access
|
|
318
|
+
if kind == "item" and (name is None or (owner, "item", None) in reads):
|
|
319
|
+
return False
|
|
320
|
+
return access not in reads
|
|
321
|
+
|
|
322
|
+
def access(self, value: Value) -> tuple[Value, str, object] | None:
|
|
323
|
+
"""``(object, "attribute" or "item", name or constant key)`` for a read of an
|
|
324
|
+
attribute or an item; ``None`` for any other value, and as the key when unknown."""
|
|
325
|
+
|
|
326
|
+
definition = self.defs.get(value)
|
|
327
|
+
if isinstance(definition, GetAttr):
|
|
328
|
+
return definition.object, "attribute", definition.attribute
|
|
329
|
+
if isinstance(definition, GetItem):
|
|
330
|
+
key = self.defs.get(definition.key)
|
|
331
|
+
return definition.object, "item", key.value if isinstance(key, Constant) else None
|
|
332
|
+
return None
|
|
333
|
+
|
|
270
334
|
def is_constant_collection(self, value: Value) -> bool:
|
|
271
335
|
definition = self.defs.get(value)
|
|
272
336
|
if isinstance(definition, Constant):
|
|
@@ -326,8 +390,9 @@ class _Judge:
|
|
|
326
390
|
for value, reason in found.items():
|
|
327
391
|
if value in chain:
|
|
328
392
|
proven.setdefault(value, reason)
|
|
393
|
+
examined = self.examined(guard.condition, mentioned, flow.argument, chain)
|
|
329
394
|
for origin in origins:
|
|
330
|
-
if origin not in mentions and
|
|
395
|
+
if origin not in mentions and examined & (
|
|
331
396
|
{origin} | {w for w in chain if origin in self.closure(w)}
|
|
332
397
|
):
|
|
333
398
|
mentions[origin] = guard.line
|
|
@@ -87,11 +87,13 @@ EMPTY = Dep()
|
|
|
87
87
|
|
|
88
88
|
@dataclass(frozen=True)
|
|
89
89
|
class ExternalCall:
|
|
90
|
-
"""An external symbol reached from this function; dependencies are parameter indices.
|
|
90
|
+
"""An external symbol reached from this function; dependencies are parameter indices.
|
|
91
|
+
Keyword arguments keep their name, so a sink reading ``url=`` only can be decided;
|
|
92
|
+
``*args`` and ``**kwargs`` have none."""
|
|
91
93
|
|
|
92
94
|
symbol: SymbolId
|
|
93
95
|
argument_dependencies: tuple[Dependencies, ...]
|
|
94
|
-
keyword_dependencies: Dependencies
|
|
96
|
+
keyword_dependencies: tuple[tuple[str | None, Dependencies], ...]
|
|
95
97
|
location: SourceSpan
|
|
96
98
|
call_site: SourceSpan | None
|
|
97
99
|
# What the arguments of the call at ``location`` denote, where it is made.
|
|
@@ -313,9 +315,13 @@ class _DependenceProblem(DataflowProblem[State]):
|
|
|
313
315
|
|
|
314
316
|
def call(self, call: Call, state: dict[Key, Dep]) -> Dep:
|
|
315
317
|
arguments = tuple(self.deep(a, state) for a in call.arguments)
|
|
318
|
+
named = (
|
|
319
|
+
*((name, self.deep(value, state)) for name, value in call.keywords),
|
|
320
|
+
*((None, self.deep(value, state)) for value in call.starred),
|
|
321
|
+
)
|
|
316
322
|
keywords = EMPTY
|
|
317
|
-
for
|
|
318
|
-
keywords |=
|
|
323
|
+
for _, deps in named:
|
|
324
|
+
keywords |= deps
|
|
319
325
|
everything = keywords
|
|
320
326
|
for deps in arguments:
|
|
321
327
|
everything |= deps
|
|
@@ -335,7 +341,7 @@ class _DependenceProblem(DataflowProblem[State]):
|
|
|
335
341
|
self.record(
|
|
336
342
|
target.symbol,
|
|
337
343
|
tuple(a.parameters for a in arguments),
|
|
338
|
-
|
|
344
|
+
tuple((name, deps.parameters) for name, deps in named),
|
|
339
345
|
call.location,
|
|
340
346
|
None,
|
|
341
347
|
self.graph.arguments_at(self.name, call.location),
|
|
@@ -418,7 +424,7 @@ class _DependenceProblem(DataflowProblem[State]):
|
|
|
418
424
|
self.record(
|
|
419
425
|
reached.symbol,
|
|
420
426
|
tuple(mapped(d).parameters for d in reached.argument_dependencies),
|
|
421
|
-
mapped(reached.keyword_dependencies)
|
|
427
|
+
tuple((name, mapped(d).parameters) for name, d in reached.keyword_dependencies),
|
|
422
428
|
reached.location,
|
|
423
429
|
call.location,
|
|
424
430
|
reached.arguments,
|
|
@@ -435,7 +441,7 @@ class _DependenceProblem(DataflowProblem[State]):
|
|
|
435
441
|
self,
|
|
436
442
|
symbol: SymbolId,
|
|
437
443
|
arguments: tuple[Dependencies, ...],
|
|
438
|
-
keywords: Dependencies,
|
|
444
|
+
keywords: tuple[tuple[str | None, Dependencies], ...],
|
|
439
445
|
location: SourceSpan,
|
|
440
446
|
call_site: SourceSpan | None,
|
|
441
447
|
given: Arguments,
|
|
@@ -446,7 +452,11 @@ class _DependenceProblem(DataflowProblem[State]):
|
|
|
446
452
|
arguments = tuple(
|
|
447
453
|
a | b for a, b in zip(previous.argument_dependencies, arguments, strict=False)
|
|
448
454
|
)
|
|
449
|
-
keywords
|
|
455
|
+
# The same call records the same keywords, in the same order.
|
|
456
|
+
keywords = tuple(
|
|
457
|
+
(name, deps | old)
|
|
458
|
+
for (name, deps), (_, old) in zip(keywords, previous.keyword_dependencies, strict=False)
|
|
459
|
+
)
|
|
450
460
|
self.external[key] = ExternalCall(symbol, arguments, keywords, location, call_site, given)
|
|
451
461
|
|
|
452
462
|
|
|
@@ -24,15 +24,18 @@ from coretrace_python.taint.models import (
|
|
|
24
24
|
Source,
|
|
25
25
|
SuffixSink,
|
|
26
26
|
TaintKind,
|
|
27
|
+
TemplateRender,
|
|
27
28
|
TypedParameter,
|
|
28
29
|
Validator,
|
|
29
30
|
)
|
|
30
31
|
from coretrace_python.taint.routes import RegisteredRoutes, Routes, registered_routes
|
|
32
|
+
from coretrace_python.taint.templates import EscapedTemplates, escaped_templates
|
|
31
33
|
|
|
32
34
|
__all__ = [
|
|
33
35
|
"AuthorizationGuard",
|
|
34
36
|
"EntryPoint",
|
|
35
37
|
"EntryPointAnalysis",
|
|
38
|
+
"EscapedTemplates",
|
|
36
39
|
"Model",
|
|
37
40
|
"ModelError",
|
|
38
41
|
"ModelTable",
|
|
@@ -52,8 +55,10 @@ __all__ = [
|
|
|
52
55
|
"TaintFacts",
|
|
53
56
|
"TaintFlow",
|
|
54
57
|
"TaintKind",
|
|
58
|
+
"TemplateRender",
|
|
55
59
|
"TypedParameter",
|
|
56
60
|
"Validator",
|
|
61
|
+
"escaped_templates",
|
|
57
62
|
"propagate_taint",
|
|
58
63
|
"registered_routes",
|
|
59
64
|
]
|
coretrace_python/taint/engine.py
CHANGED
|
@@ -10,7 +10,7 @@ argument reaching a sink whose kinds it still carries is reported as a ``TaintFl
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
from collections.abc import Callable, Mapping
|
|
13
|
-
from dataclasses import dataclass
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
14
|
from types import MappingProxyType
|
|
15
15
|
from typing import ClassVar
|
|
16
16
|
|
|
@@ -79,6 +79,7 @@ from coretrace_python.taint.models import (
|
|
|
79
79
|
TaintKind,
|
|
80
80
|
)
|
|
81
81
|
from coretrace_python.taint.routes import RegisteredRoutes, Routes
|
|
82
|
+
from coretrace_python.taint.templates import EscapedTemplates
|
|
82
83
|
|
|
83
84
|
|
|
84
85
|
@dataclass(frozen=True)
|
|
@@ -152,9 +153,12 @@ class _TaintProblem(DataflowProblem[State]):
|
|
|
152
153
|
heap: HeapFacts | None = None,
|
|
153
154
|
seeds: Mapping[HeapLocation, Taint] | None = None,
|
|
154
155
|
module: str = "",
|
|
156
|
+
escaped: frozenset[str] = frozenset(),
|
|
155
157
|
) -> None:
|
|
156
158
|
self.name = name
|
|
157
159
|
self.module = module
|
|
160
|
+
# As call sites record a constant argument: the way Python writes it.
|
|
161
|
+
self.escaped = frozenset(repr(name) for name in escaped)
|
|
158
162
|
self.function = function
|
|
159
163
|
self.models = models
|
|
160
164
|
self.graph = graph
|
|
@@ -401,11 +405,17 @@ class _TaintProblem(DataflowProblem[State]):
|
|
|
401
405
|
if sink is not None:
|
|
402
406
|
for position, argument in enumerate(call.arguments):
|
|
403
407
|
self.report(flows, sink, self.rendered(argument, state), argument, call, None, None, position, given)
|
|
404
|
-
for argument in
|
|
408
|
+
for argument in call.starred:
|
|
405
409
|
self.report(flows, sink, self.rendered(argument, state), argument, call, None, None, None, given)
|
|
410
|
+
for name, argument in call.keywords:
|
|
411
|
+
self.report(
|
|
412
|
+
flows, sink, self.rendered(argument, state), argument, call, None, None, None, given, keyword=name
|
|
413
|
+
)
|
|
406
414
|
sanitizer = self.models.sanitizer(symbol)
|
|
407
415
|
if sanitizer is not None:
|
|
408
416
|
return everything.without(sanitizer.kinds)
|
|
417
|
+
if self.renders_escaped(symbol, given):
|
|
418
|
+
return everything.without(TaintKind.HTML)
|
|
409
419
|
# A method on a tainted object returns tainted data (``request.args.get``).
|
|
410
420
|
everything = everything.join(state.get(call.callee, Taint.none()))
|
|
411
421
|
source = self.models.source_covering(symbol)
|
|
@@ -413,6 +423,14 @@ class _TaintProblem(DataflowProblem[State]):
|
|
|
413
423
|
return everything.join(Taint(source.kinds, frozenset({source})))
|
|
414
424
|
return everything
|
|
415
425
|
|
|
426
|
+
def renders_escaped(self, symbol: SymbolId, given: Arguments) -> bool:
|
|
427
|
+
"""Whether the call renders, by a name written in the call, a template the
|
|
428
|
+
project shows escaping everything it renders."""
|
|
429
|
+
|
|
430
|
+
render = self.models.template_render(symbol)
|
|
431
|
+
named = given.given(render.keyword, render.position) if render is not None else None
|
|
432
|
+
return named is not None and named[0] and named[1] in self.escaped
|
|
433
|
+
|
|
416
434
|
def guarded(self, sink: Sink | None, arguments: Arguments) -> Sink | None:
|
|
417
435
|
"""The sink as this call makes it: an argument declared safe (``yaml.load`` with
|
|
418
436
|
``Loader=SafeLoader``) takes off the kinds it covers, and no kind left, no sink."""
|
|
@@ -424,7 +442,7 @@ class _TaintProblem(DataflowProblem[State]):
|
|
|
424
442
|
if safe is None or given is None or not given[0] or given[1] not in safe.values:
|
|
425
443
|
return sink
|
|
426
444
|
kinds = sink.kinds & ~safe.kinds
|
|
427
|
-
return
|
|
445
|
+
return replace(sink, kinds=kinds) if kinds else None
|
|
428
446
|
|
|
429
447
|
def rendered(self, argument: Value, state: Mapping[Key, Taint]) -> Taint:
|
|
430
448
|
"""The taint an argument brings to a sink. A container literal is serialised by
|
|
@@ -497,11 +515,16 @@ class _TaintProblem(DataflowProblem[State]):
|
|
|
497
515
|
sink = self.guarded(self.models.sink(reached.symbol), reached.arguments)
|
|
498
516
|
if sink is None:
|
|
499
517
|
continue
|
|
500
|
-
|
|
501
|
-
for position, deps in zip(positions, (*reached.argument_dependencies, reached.keyword_dependencies), strict=True):
|
|
518
|
+
for position, deps in enumerate(reached.argument_dependencies):
|
|
502
519
|
taint, witness = mapped(deps)
|
|
503
520
|
if witness is not None:
|
|
504
521
|
self.report(flows, sink, taint, witness, call, through, reached.location, position, reached.arguments)
|
|
522
|
+
for name, deps in reached.keyword_dependencies:
|
|
523
|
+
taint, witness = mapped(deps)
|
|
524
|
+
if witness is not None:
|
|
525
|
+
self.report(
|
|
526
|
+
flows, sink, taint, witness, call, through, reached.location, None, reached.arguments, keyword=name
|
|
527
|
+
)
|
|
505
528
|
for mutation in summary.mutations:
|
|
506
529
|
if mutation.parameter < len(values):
|
|
507
530
|
stored = mapped(mutation.dependencies)[0]
|
|
@@ -528,8 +551,9 @@ class _TaintProblem(DataflowProblem[State]):
|
|
|
528
551
|
sink_location: SourceSpan | None,
|
|
529
552
|
position: int | None = None,
|
|
530
553
|
arguments: Arguments | None = None,
|
|
554
|
+
keyword: str | None = None,
|
|
531
555
|
) -> None:
|
|
532
|
-
reaching = taint.kinds & sink.kinds_at(position)
|
|
556
|
+
reaching = taint.kinds & sink.kinds_at(position, keyword)
|
|
533
557
|
if not reaching:
|
|
534
558
|
return
|
|
535
559
|
for source in sorted(taint.sources, key=lambda s: str(s.symbol)):
|
|
@@ -792,8 +816,9 @@ def propagate_taint(
|
|
|
792
816
|
heap: HeapFacts | None = None,
|
|
793
817
|
seeds: Mapping[HeapLocation, Taint] | None = None,
|
|
794
818
|
module: str = "",
|
|
819
|
+
escaped: frozenset[str] = frozenset(),
|
|
795
820
|
) -> TaintFacts:
|
|
796
|
-
problem = _TaintProblem(name, function, models, graph, summaries, parameters, project, heap, seeds, module)
|
|
821
|
+
problem = _TaintProblem(name, function, models, graph, summaries, parameters, project, heap, seeds, module, escaped)
|
|
797
822
|
solution = solve(problem, cfg)
|
|
798
823
|
taints: dict[Key, Taint] = {}
|
|
799
824
|
flows: list[TaintFlow] = []
|
|
@@ -821,6 +846,7 @@ class TaintAnalysis(FunctionAnalysis[TaintFacts]):
|
|
|
821
846
|
ProjectSummaries,
|
|
822
847
|
HeapAnalysis,
|
|
823
848
|
RegisteredRoutes,
|
|
849
|
+
EscapedTemplates,
|
|
824
850
|
}
|
|
825
851
|
)
|
|
826
852
|
|
|
@@ -857,6 +883,7 @@ class TaintAnalysis(FunctionAnalysis[TaintFacts]):
|
|
|
857
883
|
heap,
|
|
858
884
|
seeds,
|
|
859
885
|
ctx.module.name,
|
|
886
|
+
ctx.get(EscapedTemplates),
|
|
860
887
|
)
|
|
861
888
|
|
|
862
889
|
|
coretrace_python/taint/models.py
CHANGED
|
@@ -55,28 +55,42 @@ class Source:
|
|
|
55
55
|
|
|
56
56
|
@dataclass(frozen=True)
|
|
57
57
|
class Sink:
|
|
58
|
-
"""A callable whose arguments must not carry the given taint kinds. ``positions``
|
|
59
|
-
|
|
60
|
-
of ``execute``, its parameter tuple is not a statement
|
|
58
|
+
"""A callable whose arguments must not carry the given taint kinds. ``positions`` and
|
|
59
|
+
``keywords`` restrict some kinds to the arguments they list: a SQL statement is the
|
|
60
|
+
first argument of ``execute``, its parameter tuple is not a statement; the target of
|
|
61
|
+
``requests.get`` is its first argument or ``url=``, its body is no destination."""
|
|
61
62
|
|
|
62
63
|
symbol: SymbolId
|
|
63
64
|
kinds: TaintKind
|
|
64
65
|
positions: tuple[tuple[TaintKind, tuple[int, ...]], ...] = ()
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
keywords: tuple[tuple[TaintKind, tuple[str, ...]], ...] = ()
|
|
67
|
+
|
|
68
|
+
def kinds_at(self, position: int | None, keyword: str | None = None) -> TaintKind:
|
|
69
|
+
"""The kinds that must not reach the argument at ``position`` or passed as
|
|
70
|
+
``keyword``; both are ``None`` for a starred argument or ``**kwargs``, which
|
|
71
|
+
reach no restricted kind. A restricted kind reaches any argument one of its
|
|
72
|
+
restrictions lists."""
|
|
73
|
+
|
|
74
|
+
restricted = allowed = TaintKind(0)
|
|
75
|
+
for kinds, positions in self.positions:
|
|
76
|
+
restricted |= kinds
|
|
77
|
+
if position in positions:
|
|
78
|
+
allowed |= kinds
|
|
79
|
+
for kinds, names in self.keywords:
|
|
80
|
+
restricted |= kinds
|
|
81
|
+
if keyword in names:
|
|
82
|
+
allowed |= kinds
|
|
83
|
+
return self.kinds & ~(restricted & ~allowed)
|
|
75
84
|
|
|
76
85
|
def merged(self, other: Sink) -> Sink:
|
|
77
|
-
"""This sink with ``other``'s kinds and
|
|
86
|
+
"""This sink with ``other``'s kinds, positions and keywords added."""
|
|
78
87
|
|
|
79
|
-
return Sink(
|
|
88
|
+
return Sink(
|
|
89
|
+
self.symbol,
|
|
90
|
+
self.kinds | other.kinds,
|
|
91
|
+
self.positions + other.positions,
|
|
92
|
+
self.keywords + other.keywords,
|
|
93
|
+
)
|
|
80
94
|
|
|
81
95
|
|
|
82
96
|
@dataclass(frozen=True)
|
|
@@ -173,6 +187,17 @@ class Validator:
|
|
|
173
187
|
argument: int = 0
|
|
174
188
|
|
|
175
189
|
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class TemplateRender:
|
|
192
|
+
"""A call rendering the template it names (argument ``position``, or ``keyword``)
|
|
193
|
+
with autoescaping on, such as ``render_to_string``: what it returns carries no
|
|
194
|
+
``HTML`` when the project shows that template escaping everything it renders."""
|
|
195
|
+
|
|
196
|
+
symbol: SymbolId
|
|
197
|
+
position: int = 0
|
|
198
|
+
keyword: str = "template_name"
|
|
199
|
+
|
|
200
|
+
|
|
176
201
|
@dataclass(frozen=True)
|
|
177
202
|
class AuthorizationGuard:
|
|
178
203
|
"""A decorator, or a condition, that restricts who reaches the code behind it; a
|
|
@@ -194,6 +219,7 @@ Model = (
|
|
|
194
219
|
| RouteRegistrar
|
|
195
220
|
| SuffixSink
|
|
196
221
|
| SafeArgument
|
|
222
|
+
| TemplateRender
|
|
197
223
|
)
|
|
198
224
|
|
|
199
225
|
|
|
@@ -210,6 +236,7 @@ class ModelTable:
|
|
|
210
236
|
route_registrars: tuple[RouteRegistrar, ...] = ()
|
|
211
237
|
suffix_sinks: tuple[SuffixSink, ...] = ()
|
|
212
238
|
safe_arguments: tuple[SafeArgument, ...] = ()
|
|
239
|
+
template_renders: tuple[TemplateRender, ...] = ()
|
|
213
240
|
_by_symbol: dict[type[Model], dict[SymbolId, Model]] = field(
|
|
214
241
|
init=False, repr=False, compare=False
|
|
215
242
|
)
|
|
@@ -225,6 +252,7 @@ class ModelTable:
|
|
|
225
252
|
AuthorizationGuard: {m.symbol: m for m in self.authorizations},
|
|
226
253
|
RouteRegistrar: {m.symbol: m for m in self.route_registrars},
|
|
227
254
|
SafeArgument: {m.symbol: m for m in self.safe_arguments},
|
|
255
|
+
TemplateRender: {m.symbol: m for m in self.template_renders},
|
|
228
256
|
}
|
|
229
257
|
object.__setattr__(self, "_by_symbol", MappingProxyType(index))
|
|
230
258
|
|
|
@@ -291,6 +319,7 @@ class ModelTable:
|
|
|
291
319
|
self.route_registrars,
|
|
292
320
|
self.suffix_sinks,
|
|
293
321
|
self.safe_arguments,
|
|
322
|
+
self.template_renders,
|
|
294
323
|
)
|
|
295
324
|
|
|
296
325
|
def sanitizer(self, symbol: SymbolId) -> Sanitizer | None:
|
|
@@ -301,6 +330,10 @@ class ModelTable:
|
|
|
301
330
|
found = self._by_symbol[SafeArgument].get(symbol)
|
|
302
331
|
return found if isinstance(found, SafeArgument) else None
|
|
303
332
|
|
|
333
|
+
def template_render(self, symbol: SymbolId) -> TemplateRender | None:
|
|
334
|
+
found = self._by_symbol[TemplateRender].get(symbol)
|
|
335
|
+
return found if isinstance(found, TemplateRender) else None
|
|
336
|
+
|
|
304
337
|
|
|
305
338
|
class SecurityModelRegistry:
|
|
306
339
|
"""Mutable collection point for the models plugins register."""
|
|
@@ -347,6 +380,7 @@ class SecurityModelRegistry:
|
|
|
347
380
|
route_registrars=tuple(m for m in models if isinstance(m, RouteRegistrar)),
|
|
348
381
|
suffix_sinks=tuple(m for m in models if isinstance(m, SuffixSink)),
|
|
349
382
|
safe_arguments=tuple(m for m in models if isinstance(m, SafeArgument)),
|
|
383
|
+
template_renders=tuple(m for m in models if isinstance(m, TemplateRender)),
|
|
350
384
|
)
|
|
351
385
|
|
|
352
386
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Django templates that escape everything they render (architecture §17).
|
|
2
|
+
|
|
3
|
+
``render_to_string(name, context)`` returns HTML in which autoescaping escaped every
|
|
4
|
+
variable, unless the template says otherwise. The engine does not render templates; it
|
|
5
|
+
reads them, and establishes that a template escapes only when nothing in it, or in what
|
|
6
|
+
it extends or includes, can output data unescaped:
|
|
7
|
+
|
|
8
|
+
- no ``|safe`` or ``|safeseq``, no ``{% autoescape off %}``, and no tag, filter or tag
|
|
9
|
+
library beyond Django's own, whose output a custom one could mark safe;
|
|
10
|
+
- no variable where HTML escaping does not protect it: in a ``<script>`` or ``<style>``
|
|
11
|
+
block, an event handler, a ``style`` or ``srcdoc`` attribute, a URL attribute whose
|
|
12
|
+
value does not start with a fixed relative or ``http(s)`` prefix, an unquoted
|
|
13
|
+
attribute, an attribute or a tag name;
|
|
14
|
+
- no Python file of the project turning autoescaping off.
|
|
15
|
+
|
|
16
|
+
Templates are found under the project's ``templates`` directories, by their path below
|
|
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.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import ClassVar
|
|
27
|
+
|
|
28
|
+
from coretrace_python.analysis import Analysis, AnalysisContext
|
|
29
|
+
from coretrace_python.interprocedural import discover_files
|
|
30
|
+
|
|
31
|
+
TEMPLATES_DIRECTORY = "templates"
|
|
32
|
+
|
|
33
|
+
_OUTPUT, _TAG, _URL = "\x00", "\x01", "/u"
|
|
34
|
+
_SYNTAX = re.compile(r"{{(.*?)}}|{%(.*?)%}|{#.*?#}", re.DOTALL)
|
|
35
|
+
_AUTOESCAPE_OFF = re.compile(r"""["']autoescape["']\s*:\s*False|\bautoescape\s*=\s*False""")
|
|
36
|
+
|
|
37
|
+
# Django's own libraries, tags and filters. Every filter but ``safe`` and ``safeseq``
|
|
38
|
+
# escapes its input, or returns it for autoescaping to escape, when autoescaping is on.
|
|
39
|
+
_LIBRARIES = frozenset({"static", "i18n", "l10n", "tz", "cache", "humanize"})
|
|
40
|
+
_TAGS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"load", "extends", "include", "block", "endblock", "if", "elif", "else", "endif",
|
|
43
|
+
"for", "empty", "endfor", "with", "endwith", "ifchanged", "endifchanged", "spaceless",
|
|
44
|
+
"endspaceless", "filter", "endfilter", "autoescape", "endautoescape", "csrf_token",
|
|
45
|
+
"now", "lorem", "widthratio", "templatetag", "regroup", "resetcycle", "url", "static",
|
|
46
|
+
"get_static_prefix", "get_media_prefix", "firstof", "cycle", "trans", "translate",
|
|
47
|
+
"blocktrans", "blocktranslate", "endblocktrans", "endblocktranslate", "plural",
|
|
48
|
+
"get_current_language", "get_current_language_bidi", "get_available_languages",
|
|
49
|
+
"get_language_info", "get_language_info_list", "language", "endlanguage", "localize",
|
|
50
|
+
"endlocalize", "localtime", "endlocaltime", "timezone", "endtimezone",
|
|
51
|
+
"get_current_timezone", "cache", "endcache",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
# Tags that print a value: checked where they stand, like a variable.
|
|
55
|
+
_PRINTING = frozenset({"firstof", "cycle", "trans", "translate"})
|
|
56
|
+
_FILTERS = frozenset(
|
|
57
|
+
{
|
|
58
|
+
"add", "addslashes", "capfirst", "center", "cut", "date", "default", "default_if_none",
|
|
59
|
+
"dictsort", "dictsortreversed", "divisibleby", "escape", "escapejs", "escapeseq",
|
|
60
|
+
"filesizeformat", "first", "floatformat", "force_escape", "get_digit", "iriencode",
|
|
61
|
+
"join", "json_script", "last", "length", "length_is", "linebreaks", "linebreaksbr",
|
|
62
|
+
"linenumbers", "ljust", "lower", "make_list", "phone2numeric", "pluralize", "pprint",
|
|
63
|
+
"random", "rjust", "slice", "slugify", "stringformat", "striptags", "time", "timesince",
|
|
64
|
+
"timeuntil", "title", "truncatechars", "truncatechars_html", "truncatewords",
|
|
65
|
+
"truncatewords_html", "unordered_list", "upper", "urlencode", "urlize", "urlizetrunc",
|
|
66
|
+
"wordcount", "wordwrap", "yesno", "language_name", "language_name_local",
|
|
67
|
+
"language_bidi", "language_name_translated", "localize", "unlocalize", "localtime",
|
|
68
|
+
"utc", "timezone", "apnumber", "intcomma", "intword", "naturalday", "naturaltime",
|
|
69
|
+
"ordinal",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_URL_ATTRIBUTES = frozenset(
|
|
74
|
+
{
|
|
75
|
+
"href", "src", "action", "formaction", "poster", "background", "cite", "data",
|
|
76
|
+
"srcset", "ping", "manifest", "longdesc", "usemap", "codebase", "classid", "archive",
|
|
77
|
+
"profile", "lowsrc", "dynsrc", "icon", "xlink:href",
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
# A relative path (not ``//host``), a fragment, a query, or an http(s) URL whose host is
|
|
81
|
+
# fixed: no scheme such as ``javascript:`` can follow.
|
|
82
|
+
_SAFE_URL_PREFIX = re.compile(r"(?:/(?!/)|\.{1,2}/|[#?]|https?://[^/?#\x00\x01]+[/?#])")
|
|
83
|
+
_QUOTED = r"\"[^\"]*\"|'[^']*'"
|
|
84
|
+
_ATTRIBUTE = re.compile(rf"([^\s\"'>/=]+)(?:\s*=\s*({_QUOTED}|[^\s\"'=<>`]+))?")
|
|
85
|
+
_START_TAG = re.compile(rf"<([A-Za-z][^\s/>]*)((?:\s*[^\s\"'>/=]+(?:\s*=\s*(?:{_QUOTED}|[^\s\"'=<>`]+))?)*)\s*/?>")
|
|
86
|
+
_RAW_TEXT = re.compile(r"<(script|style)\b[^>]*>(.*?)</\1\s*>", re.DOTALL | re.IGNORECASE)
|
|
87
|
+
_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
88
|
+
_END_TAG = re.compile(r"</[A-Za-z][^>]*>")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def escaped_templates(root: Path) -> frozenset[str]:
|
|
92
|
+
"""The names of the templates under ``root`` that escape every variable they render,
|
|
93
|
+
as ``render_to_string`` names them (their path below a ``templates`` directory)."""
|
|
94
|
+
|
|
95
|
+
found: dict[str, list[Path]] = {}
|
|
96
|
+
for path in discover_files(root):
|
|
97
|
+
if path.suffix == ".py" and _AUTOESCAPE_OFF.search(_read(path)):
|
|
98
|
+
return frozenset()
|
|
99
|
+
for name in _names(path.relative_to(root).parts):
|
|
100
|
+
found.setdefault(name, []).append(path)
|
|
101
|
+
local: dict[str, bool] = {}
|
|
102
|
+
requires: dict[str, set[str]] = {}
|
|
103
|
+
for name, paths in found.items():
|
|
104
|
+
verdicts = [_inspect(_read(path)) for path in paths]
|
|
105
|
+
local[name] = all(safe for safe, _ in verdicts)
|
|
106
|
+
requires[name] = {needed for _, needs in verdicts for needed in needs}
|
|
107
|
+
escaped = {name for name, safe in local.items() if safe}
|
|
108
|
+
changed = True
|
|
109
|
+
while changed:
|
|
110
|
+
kept = {name for name in escaped if requires[name] <= escaped}
|
|
111
|
+
changed = kept != escaped
|
|
112
|
+
escaped = kept
|
|
113
|
+
return frozenset(escaped)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _names(parts: tuple[str, ...]) -> Iterator[str]:
|
|
117
|
+
for index, part in enumerate(parts[:-1]):
|
|
118
|
+
if part == TEMPLATES_DIRECTORY:
|
|
119
|
+
yield "/".join(parts[index + 1 :])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _read(path: Path) -> str:
|
|
123
|
+
try:
|
|
124
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
125
|
+
except OSError:
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _inspect(text: str) -> tuple[bool, set[str]]:
|
|
130
|
+
"""Whether the template escapes all it prints itself, and the templates it extends
|
|
131
|
+
or includes, which must escape too."""
|
|
132
|
+
|
|
133
|
+
requires: set[str] = set()
|
|
134
|
+
html: list[str] = []
|
|
135
|
+
skipping: str | None = None
|
|
136
|
+
position = 0
|
|
137
|
+
text = text.replace(_OUTPUT, "").replace(_TAG, "")
|
|
138
|
+
for match in _SYNTAX.finditer(text):
|
|
139
|
+
variable, tag = match.group(1), match.group(2)
|
|
140
|
+
words = tag.split() if tag is not None else []
|
|
141
|
+
if skipping is not None:
|
|
142
|
+
if words[:1] == [skipping]:
|
|
143
|
+
if skipping == "endverbatim":
|
|
144
|
+
html.append(text[position : match.start()])
|
|
145
|
+
skipping, position = None, match.end()
|
|
146
|
+
continue
|
|
147
|
+
html.append(text[position : match.start()])
|
|
148
|
+
position = match.end()
|
|
149
|
+
if variable is not None:
|
|
150
|
+
if not _escaping_filters(variable):
|
|
151
|
+
return False, requires
|
|
152
|
+
html.append(_OUTPUT)
|
|
153
|
+
elif words and words[0] in ("comment", "verbatim"):
|
|
154
|
+
skipping = f"end{words[0]}"
|
|
155
|
+
if words[0] == "verbatim":
|
|
156
|
+
position = match.end()
|
|
157
|
+
elif words:
|
|
158
|
+
name = words[0]
|
|
159
|
+
if name not in _TAGS:
|
|
160
|
+
return False, requires
|
|
161
|
+
if name == "load" and not set(_loaded(words[1:])) <= _LIBRARIES:
|
|
162
|
+
return False, requires
|
|
163
|
+
if name == "autoescape" and words[1:] != ["on"]:
|
|
164
|
+
return False, requires
|
|
165
|
+
if name == "filter" and not _escaping_filters(tag.split(None, 1)[1] if len(words) > 1 else ""):
|
|
166
|
+
return False, requires
|
|
167
|
+
if name in ("extends", "include"):
|
|
168
|
+
needed = _constant(words[1] if len(words) > 1 else "")
|
|
169
|
+
if needed is None:
|
|
170
|
+
return False, requires
|
|
171
|
+
requires.add(needed)
|
|
172
|
+
html.append(_OUTPUT if name in _PRINTING else _URL if name in ("url", "static") else _TAG)
|
|
173
|
+
if skipping is not None:
|
|
174
|
+
return False, requires
|
|
175
|
+
html.append(text[position:])
|
|
176
|
+
return _escapes_where_printed("".join(html)), requires
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _loaded(words: list[str]) -> list[str]:
|
|
180
|
+
"""The libraries ``{% load a b %}`` or ``{% load x y from lib %}`` loads."""
|
|
181
|
+
|
|
182
|
+
return words[words.index("from") + 1 :] if "from" in words else words
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _escaping_filters(expression: str) -> bool:
|
|
186
|
+
filters = re.sub(_QUOTED, "", expression).split("|")[1:]
|
|
187
|
+
return all(f.split(":", 1)[0].strip() in _FILTERS for f in filters)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _constant(word: str) -> str | None:
|
|
191
|
+
return word[1:-1] if len(word) >= 2 and word[0] == word[-1] and word[0] in "\"'" else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _escapes_where_printed(html: str) -> bool:
|
|
195
|
+
"""Whether every output marker of ``html`` stands where HTML escaping protects it."""
|
|
196
|
+
|
|
197
|
+
position = 0
|
|
198
|
+
while (start := html.find("<", position)) != -1:
|
|
199
|
+
rest = html[start:]
|
|
200
|
+
comment = _COMMENT.match(rest)
|
|
201
|
+
raw = _RAW_TEXT.match(rest)
|
|
202
|
+
tag = _START_TAG.match(rest)
|
|
203
|
+
end = _END_TAG.match(rest)
|
|
204
|
+
if comment:
|
|
205
|
+
position = start + comment.end()
|
|
206
|
+
elif raw:
|
|
207
|
+
if _OUTPUT in raw.group(2) or not _attributes_escape(raw.group(0)[: raw.start(2)]):
|
|
208
|
+
return False
|
|
209
|
+
position = start + raw.end()
|
|
210
|
+
elif tag:
|
|
211
|
+
if _OUTPUT in tag.group(1) or not _attributes_escape(tag.group(2)):
|
|
212
|
+
return False
|
|
213
|
+
position = start + tag.end()
|
|
214
|
+
elif end:
|
|
215
|
+
if _OUTPUT in end.group(0):
|
|
216
|
+
return False
|
|
217
|
+
position = start + end.end()
|
|
218
|
+
else:
|
|
219
|
+
# ``<{{ name }}``: the variable would name a tag, escaping cannot stop it.
|
|
220
|
+
if rest[1:].lstrip("/ \t\n").startswith(_OUTPUT):
|
|
221
|
+
return False
|
|
222
|
+
position = start + 1
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _attributes_escape(attributes: str) -> bool:
|
|
227
|
+
for match in _ATTRIBUTE.finditer(attributes):
|
|
228
|
+
name, value = match.group(1).lower(), match.group(2)
|
|
229
|
+
if _OUTPUT in name:
|
|
230
|
+
return False
|
|
231
|
+
if value is None or _OUTPUT not in value:
|
|
232
|
+
continue
|
|
233
|
+
if value[0] not in "\"'" or name.startswith("on") or name in ("style", "srcdoc"):
|
|
234
|
+
return False
|
|
235
|
+
if name in _URL_ATTRIBUTES:
|
|
236
|
+
prefix = value[1:].split(_OUTPUT, 1)[0]
|
|
237
|
+
if _TAG in prefix or not _SAFE_URL_PREFIX.match(prefix):
|
|
238
|
+
return False
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class EscapedTemplates(Analysis[frozenset[str]]):
|
|
243
|
+
"""The names of the project templates that escape everything they render, provided
|
|
244
|
+
by the engine for a project; none on its own."""
|
|
245
|
+
|
|
246
|
+
name: ClassVar[str] = "taint.escaped_templates"
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def compute(cls, ctx: AnalysisContext) -> frozenset[str]:
|
|
250
|
+
return frozenset()
|
{coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: coretrace-python-analyzer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
{coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/RECORD
RENAMED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
coretrace_python/__init__.py,sha256=
|
|
1
|
+
coretrace_python/__init__.py,sha256=kEx6lE06VX-mfQ6o7DrayGZNlR2uTGSdGItAWsSIeHI,75
|
|
2
2
|
coretrace_python/__main__.py,sha256=V8DnYtRboISK2VCKm7DGHLREKvqLbqApyWlKBOs26wU,65
|
|
3
|
-
coretrace_python/cache.py,sha256=
|
|
3
|
+
coretrace_python/cache.py,sha256=tt-l1jOibCx0lNKGd7XF_xTlcCeBLewPWOPpS-e3Ynk,12929
|
|
4
4
|
coretrace_python/cli.py,sha256=SwXYUR8DIgppkqom6kKNM701pb4p2omEy-hRXIM5yZE,10946
|
|
5
|
-
coretrace_python/engine.py,sha256=
|
|
5
|
+
coretrace_python/engine.py,sha256=3NxNKzkNTWxa7a4NVkZ0fyh5KitNpDHuTsCrdBa4VxU,28696
|
|
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
|
|
@@ -29,13 +29,13 @@ coretrace_python/bundled/models/credentials/credential_models.py,sha256=Mw9ty8PD
|
|
|
29
29
|
coretrace_python/bundled/models/credentials/plugin.toml,sha256=0ut7Jc7IdcvbsZKPu6ty-z3DBlMg2CUT7JX4y4nMybo,217
|
|
30
30
|
coretrace_python/bundled/models/db_drivers/db_driver_models.py,sha256=x4SAwhcpuG5TLfhmVsuAQ1VV3lAXSNh5YHx7QuFBQyU,2339
|
|
31
31
|
coretrace_python/bundled/models/db_drivers/plugin.toml,sha256=094-QIV92x-69uy2CgMulBXfGvt48qtefLyeW2q3SRs,178
|
|
32
|
-
coretrace_python/bundled/models/django/django_models.py,sha256=
|
|
32
|
+
coretrace_python/bundled/models/django/django_models.py,sha256=1ftZ8tEBZJXrAiZr0rQZBSIOu4VqwOkHz8WE8FE5UBI,6445
|
|
33
33
|
coretrace_python/bundled/models/django/plugin.toml,sha256=_Qa2X_4IViORQkcLvLsAC5vI-2P5CxgG8VvnuNIWcFQ,214
|
|
34
34
|
coretrace_python/bundled/models/fastapi/fastapi_models.py,sha256=K2Edlo1RzYKmAJAIChNVUDc8gorAS48MGEMKNlCIGrI,1251
|
|
35
35
|
coretrace_python/bundled/models/fastapi/plugin.toml,sha256=V_R2WcEc-qkST0tTpL-cRf2FEbjK6Mk727WbJmVpWJM,200
|
|
36
36
|
coretrace_python/bundled/models/flask/flask_models.py,sha256=XVZWH4u2SdbErk40L-JjTBXQI1hrVCDlMu5BeqE1Ibc,2319
|
|
37
37
|
coretrace_python/bundled/models/flask/plugin.toml,sha256=kI53Xwf5aZKd2n3fEQEbBzNnOXRvuonqEazTOIXPJP4,192
|
|
38
|
-
coretrace_python/bundled/models/http_clients/http_client_models.py,sha256=
|
|
38
|
+
coretrace_python/bundled/models/http_clients/http_client_models.py,sha256=9XhHWZKq3XWcD0WexucuedAiwxp3fhXxGmo9R6DNtSQ,1954
|
|
39
39
|
coretrace_python/bundled/models/http_clients/plugin.toml,sha256=vq9tWHgy8-m8LiU1StGhm9Lsy7vnj3w90P4AKDaKfc0,216
|
|
40
40
|
coretrace_python/bundled/models/python_stdlib/plugin.toml,sha256=DWqjG68rx91HCJi_VEp6tUs4ujkzlR7Lq7saMvtK2HM,187
|
|
41
41
|
coretrace_python/bundled/models/python_stdlib/python_stdlib.py,sha256=3LmjcFx3rUhM3Mpw86q8bS5Xj6gRJs_p6M-8yBGiK2k,4632
|
|
@@ -99,7 +99,7 @@ coretrace_python/findings/__init__.py,sha256=gnJI-uB2CkgZ8I5aFYP_N5yZb7FGNIy7Lir
|
|
|
99
99
|
coretrace_python/findings/baseline.py,sha256=fbGQuULRsCjhxSNdwuLfwsvql9ZUfF3jqCXrRmIgOoQ,3468
|
|
100
100
|
coretrace_python/findings/coverage.py,sha256=U8URLEz6EN4B_TTQlLFCOtEmUbINt7a0Bexb-6OUFlI,1203
|
|
101
101
|
coretrace_python/findings/model.py,sha256=zRK1FDuLgiVY4ch6H_AN9rDxC1orNW4wc8YN3cfVFfc,1424
|
|
102
|
-
coretrace_python/findings/refutation.py,sha256=
|
|
102
|
+
coretrace_python/findings/refutation.py,sha256=MmFANBozz0sodIhGUhrgr7BCEwhw5s80kRPRMDRDIyU,20707
|
|
103
103
|
coretrace_python/findings/suppressions.py,sha256=kN4q0CA1tzeAubeYe4rUv_aRXda4PAsMZY0r8M5cuQI,2633
|
|
104
104
|
coretrace_python/frontend/__init__.py,sha256=ULI2tf_IRTMboaKM6uzTkGGh4Agy3cFjtw2H1IPrz4g,627
|
|
105
105
|
coretrace_python/frontend/ast_adapter.py,sha256=VL2CgbQqDGSr2Wh-z0EpR0gBB7T29s0xHvfT-BPx08c,21833
|
|
@@ -110,7 +110,7 @@ coretrace_python/hir/visitors.py,sha256=JFnX09hK6trLyRcWgZ5ehOG2ywX3VvkqQ1h82nah
|
|
|
110
110
|
coretrace_python/interprocedural/__init__.py,sha256=SXtIGE3DD4fADIXLDuEvyT_3oIsh7Vio3BL4U6oUm28,1162
|
|
111
111
|
coretrace_python/interprocedural/callgraph.py,sha256=E6enTawqcl4-TObdjvoFe6l1zEedvsWbnjEBD6omzy8,19827
|
|
112
112
|
coretrace_python/interprocedural/modulegraph.py,sha256=4sB7OKjBYfxtm7aMtHm2jRlqFVUNUsdnLhnv9UZ1BWA,8846
|
|
113
|
-
coretrace_python/interprocedural/summaries.py,sha256=
|
|
113
|
+
coretrace_python/interprocedural/summaries.py,sha256=9-WGS-X37KJY1ynoXXA5HNfYFLeCyiKVMAIjeWgaVLI,21333
|
|
114
114
|
coretrace_python/ir/__init__.py,sha256=ShBPYYqoQFePP-mgBjGezQ2acel0-ReOHF9vCC-G_4Q,232
|
|
115
115
|
coretrace_python/ir/defuse.py,sha256=CZEPnlgntRp4MqwR7kLldd2ZlIlBnr-V9VJAxXxwm0c,2616
|
|
116
116
|
coretrace_python/ir/lowering.py,sha256=V7r_4tEOi801NBOsPdG6pwrRHZIeVTahygYENneLQ4U,31428
|
|
@@ -137,13 +137,14 @@ coretrace_python/semantic/symbols.py,sha256=3JptnnX5_KBlSd00L_wcGlYZQdnmBvSBAhfi
|
|
|
137
137
|
coretrace_python/source/__init__.py,sha256=0Y8fvtbkETnNCJR9Zq7RS5TSgF9c22pz3SSk1KdAXF0,301
|
|
138
138
|
coretrace_python/source/manager.py,sha256=JRmMOcWfS1nocbVrdcvfQTqEfIkmnb5JP857TpGywN4,2736
|
|
139
139
|
coretrace_python/source/model.py,sha256=FZnN5mlir3tzbIpWoxnn6YtRi9UcJ_y0sZM60tKr8U4,1727
|
|
140
|
-
coretrace_python/taint/__init__.py,sha256=
|
|
141
|
-
coretrace_python/taint/engine.py,sha256=
|
|
142
|
-
coretrace_python/taint/models.py,sha256=
|
|
140
|
+
coretrace_python/taint/__init__.py,sha256=kfqCEXE5wKFLwz2XrT42_yEv9pW4vPfgg5yYBVVSr2I,1371
|
|
141
|
+
coretrace_python/taint/engine.py,sha256=uB0Mga1xeKF6LVHx601pvojb0NLAPG0DqF8fy3dM8oU,40222
|
|
142
|
+
coretrace_python/taint/models.py,sha256=i3_Sfwdqf_BGXV47jylfF3vuX6nYJcyAPiwC3xerh6g,14592
|
|
143
143
|
coretrace_python/taint/routes.py,sha256=UAJKXcw_sM9shf58FfDroxVO9bNSm4qrQdZknwUB0lo,6110
|
|
144
|
-
|
|
145
|
-
coretrace_python_analyzer-0.
|
|
146
|
-
coretrace_python_analyzer-0.
|
|
147
|
-
coretrace_python_analyzer-0.
|
|
148
|
-
coretrace_python_analyzer-0.
|
|
149
|
-
coretrace_python_analyzer-0.
|
|
144
|
+
coretrace_python/taint/templates.py,sha256=8Ojb43VXuWxOVifeXRpi3_2mjZfnvIC7GQ-AW80GUYk,11148
|
|
145
|
+
coretrace_python_analyzer-0.7.0.dist-info/METADATA,sha256=lak3Dd-Dpbv_TEo-PAhnBg5r6iwrJmsDM5aa6wogg1w,3216
|
|
146
|
+
coretrace_python_analyzer-0.7.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
147
|
+
coretrace_python_analyzer-0.7.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
|
|
148
|
+
coretrace_python_analyzer-0.7.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
149
|
+
coretrace_python_analyzer-0.7.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
|
|
150
|
+
coretrace_python_analyzer-0.7.0.dist-info/RECORD,,
|
{coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|