coretrace-python-analyzer 0.6.0__py3-none-any.whl → 0.8.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 +18 -3
- coretrace_python/cli.py +2 -0
- coretrace_python/engine.py +39 -7
- coretrace_python/findings/refutation.py +111 -20
- coretrace_python/interprocedural/__init__.py +8 -0
- coretrace_python/interprocedural/summaries.py +144 -31
- coretrace_python/plugins/api.py +8 -0
- coretrace_python/taint/__init__.py +5 -0
- coretrace_python/taint/engine.py +33 -14
- coretrace_python/taint/models.py +61 -15
- coretrace_python/taint/templates.py +250 -0
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.8.0.dist-info}/METADATA +1 -1
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.8.0.dist-info}/RECORD +20 -19
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.8.0.dist-info}/WHEEL +0 -0
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.8.0.dist-info}/entry_points.txt +0 -0
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {coretrace_python_analyzer-0.6.0.dist-info → coretrace_python_analyzer-0.8.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
|
@@ -25,6 +25,7 @@ from coretrace_python.findings import Confidence, Finding, Severity
|
|
|
25
25
|
from coretrace_python.interprocedural import (
|
|
26
26
|
Arguments,
|
|
27
27
|
CallSite,
|
|
28
|
+
Cleared,
|
|
28
29
|
ExternalCall,
|
|
29
30
|
ExternalSymbol,
|
|
30
31
|
FunctionSummary,
|
|
@@ -41,7 +42,7 @@ from coretrace_python.interprocedural import (
|
|
|
41
42
|
from coretrace_python.semantic.symbols import SymbolId
|
|
42
43
|
from coretrace_python.source import SourceId, SourceSpan
|
|
43
44
|
|
|
44
|
-
CACHE_FORMAT =
|
|
45
|
+
CACHE_FORMAT = 10
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
@dataclass(frozen=True)
|
|
@@ -233,10 +234,12 @@ def _encode_call(call: ExternalCall) -> dict[str, Any]:
|
|
|
233
234
|
return {
|
|
234
235
|
"symbol": str(call.symbol),
|
|
235
236
|
"arguments": [sorted(deps) for deps in call.argument_dependencies],
|
|
236
|
-
"keywords": sorted(call.keyword_dependencies
|
|
237
|
+
"keywords": [[name, sorted(deps)] for name, deps in call.keyword_dependencies],
|
|
237
238
|
"location": _encode_span(call.location),
|
|
238
239
|
"call_site": None if call.call_site is None else _encode_span(call.call_site),
|
|
239
240
|
"given": _encode_arguments(call.arguments),
|
|
241
|
+
"argument_cleared": [_encode_cleared(c) for c in call.argument_cleared],
|
|
242
|
+
"keyword_cleared": [_encode_cleared(c) for c in call.keyword_cleared],
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
|
|
@@ -245,13 +248,23 @@ def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
|
|
|
245
248
|
return ExternalCall(
|
|
246
249
|
SymbolId(_string(data["symbol"])),
|
|
247
250
|
tuple(_indices(deps) for deps in data["arguments"]),
|
|
248
|
-
_indices(data["keywords"]),
|
|
251
|
+
tuple((None if name is None else _string(name), _indices(deps)) for name, deps in data["keywords"]),
|
|
249
252
|
_decode_span(data["location"]),
|
|
250
253
|
None if site is None else _decode_span(site),
|
|
251
254
|
_decode_arguments(data["given"]),
|
|
255
|
+
tuple(_decode_cleared(c) for c in data["argument_cleared"]),
|
|
256
|
+
tuple(_decode_cleared(c) for c in data["keyword_cleared"]),
|
|
252
257
|
)
|
|
253
258
|
|
|
254
259
|
|
|
260
|
+
def _encode_cleared(cleared: Cleared) -> list[list[int]]:
|
|
261
|
+
return [[parameter, bits] for parameter, bits in cleared]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _decode_cleared(data: Any) -> Cleared:
|
|
265
|
+
return tuple((_integer(parameter), _integer(bits)) for parameter, bits in data)
|
|
266
|
+
|
|
267
|
+
|
|
255
268
|
def _encode_summary(summary: FunctionSummary) -> dict[str, Any]:
|
|
256
269
|
return {
|
|
257
270
|
"name": summary.name,
|
|
@@ -275,6 +288,7 @@ def _encode_summary(summary: FunctionSummary) -> dict[str, Any]:
|
|
|
275
288
|
for w in summary.nonlocal_writes
|
|
276
289
|
],
|
|
277
290
|
"static": summary.static,
|
|
291
|
+
"return_cleared": _encode_cleared(summary.return_cleared),
|
|
278
292
|
}
|
|
279
293
|
|
|
280
294
|
|
|
@@ -303,6 +317,7 @@ def _decode_summary(data: Mapping[str, Any]) -> FunctionSummary:
|
|
|
303
317
|
for w in data["nonlocal_writes"]
|
|
304
318
|
),
|
|
305
319
|
bool(data["static"]),
|
|
320
|
+
_decode_cleared(data["return_cleared"]),
|
|
306
321
|
)
|
|
307
322
|
|
|
308
323
|
|
coretrace_python/cli.py
CHANGED
|
@@ -27,6 +27,7 @@ from coretrace_python.reporters import FORMATS, render
|
|
|
27
27
|
from coretrace_python.semantic.imports import ImportResolutionError
|
|
28
28
|
from coretrace_python.semantic.scopes import ScopeError
|
|
29
29
|
from coretrace_python.source import SourceManager
|
|
30
|
+
from coretrace_python.taint import ModelError
|
|
30
31
|
|
|
31
32
|
EXIT_CLEAN = 0
|
|
32
33
|
EXIT_FINDINGS = 1
|
|
@@ -45,6 +46,7 @@ _ANALYSIS_ERRORS = (
|
|
|
45
46
|
AnalysisError,
|
|
46
47
|
ManifestError,
|
|
47
48
|
IncompatiblePluginError,
|
|
49
|
+
ModelError,
|
|
48
50
|
)
|
|
49
51
|
|
|
50
52
|
|
coretrace_python/engine.py
CHANGED
|
@@ -70,6 +70,7 @@ from coretrace_python.interprocedural import (
|
|
|
70
70
|
CallGraph,
|
|
71
71
|
CallGraphAnalysis,
|
|
72
72
|
CallSite,
|
|
73
|
+
ClearingAnalysis,
|
|
73
74
|
FunctionSummary,
|
|
74
75
|
ModuleFunction,
|
|
75
76
|
ModuleGraph,
|
|
@@ -109,6 +110,7 @@ from coretrace_python.source import SourceFile, SourceId, SourceManager, SourceS
|
|
|
109
110
|
from coretrace_python.taint import (
|
|
110
111
|
EntryPoint,
|
|
111
112
|
EntryPointAnalysis,
|
|
113
|
+
EscapedTemplates,
|
|
112
114
|
ModelTable,
|
|
113
115
|
RegisteredRoutes,
|
|
114
116
|
Routes,
|
|
@@ -116,6 +118,7 @@ from coretrace_python.taint import (
|
|
|
116
118
|
SecurityModelRegistry,
|
|
117
119
|
TaintAnalysis,
|
|
118
120
|
TaintKind,
|
|
121
|
+
escaped_templates,
|
|
119
122
|
registered_routes,
|
|
120
123
|
)
|
|
121
124
|
|
|
@@ -146,6 +149,8 @@ ALL_ANALYSES: tuple[AnyAnalysis, ...] = (
|
|
|
146
149
|
RefutationAnalysis,
|
|
147
150
|
DependencyAnalysis,
|
|
148
151
|
RegisteredRoutes,
|
|
152
|
+
EscapedTemplates,
|
|
153
|
+
ClearingAnalysis,
|
|
149
154
|
)
|
|
150
155
|
|
|
151
156
|
|
|
@@ -178,7 +183,15 @@ class ResultsEvicted(TransformationPass):
|
|
|
178
183
|
|
|
179
184
|
name: ClassVar[str] = "project.results-evicted"
|
|
180
185
|
preserves: ClassVar[frozenset[AnyAnalysis]] = frozenset(
|
|
181
|
-
{
|
|
186
|
+
{
|
|
187
|
+
*SEMANTIC_ANALYSES,
|
|
188
|
+
SecurityModelAnalysis,
|
|
189
|
+
ProjectSummaries,
|
|
190
|
+
DependencyAnalysis,
|
|
191
|
+
RegisteredRoutes,
|
|
192
|
+
EscapedTemplates,
|
|
193
|
+
ClearingAnalysis,
|
|
194
|
+
}
|
|
182
195
|
)
|
|
183
196
|
|
|
184
197
|
@classmethod
|
|
@@ -236,7 +249,9 @@ def build_manager(
|
|
|
236
249
|
"""A manager with every engine analysis registered and the engine inputs provided."""
|
|
237
250
|
|
|
238
251
|
manager = _register_all(module)
|
|
239
|
-
|
|
252
|
+
table = (models or SecurityModelRegistry()).freeze()
|
|
253
|
+
manager.provide(SecurityModelAnalysis, table)
|
|
254
|
+
manager.provide(ClearingAnalysis, table.clearing())
|
|
240
255
|
manager.provide(ProjectSummaries, SummaryIndex())
|
|
241
256
|
manager.provide(RegisteredRoutes, _routes_of(manager))
|
|
242
257
|
return manager
|
|
@@ -266,10 +281,14 @@ def load_plugins(plugin_roots: Sequence[Path], manager: AnalysisManager) -> Plug
|
|
|
266
281
|
return registry
|
|
267
282
|
|
|
268
283
|
|
|
269
|
-
def plugin_models(plugins: Iterable[Plugin]) -> ModelTable:
|
|
284
|
+
def plugin_models(plugins: Iterable[Plugin], root: Path | None = None) -> ModelTable:
|
|
285
|
+
"""The models of ``plugins``, with those they read from the project at ``root``."""
|
|
286
|
+
|
|
270
287
|
models = SecurityModelRegistry()
|
|
271
288
|
for plugin in plugins:
|
|
272
289
|
models.register(*plugin.models, origin=plugin.name)
|
|
290
|
+
if root is not None:
|
|
291
|
+
models.register(*plugin.project_models(root), origin=plugin.name)
|
|
273
292
|
return models.freeze()
|
|
274
293
|
|
|
275
294
|
|
|
@@ -284,7 +303,9 @@ def analyze_file(source: SourceFile, plugin_roots: Sequence[Path]) -> FileAnalys
|
|
|
284
303
|
|
|
285
304
|
manager = _register_all(build_hir(source))
|
|
286
305
|
registry = load_plugins(plugin_roots, manager)
|
|
287
|
-
|
|
306
|
+
table = plugin_models(loaded.plugin for loaded in registry)
|
|
307
|
+
manager.provide(SecurityModelAnalysis, table)
|
|
308
|
+
manager.provide(ClearingAnalysis, table.clearing())
|
|
288
309
|
manager.provide(ProjectSummaries, SummaryIndex())
|
|
289
310
|
manager.provide(RegisteredRoutes, _routes_of(manager))
|
|
290
311
|
findings, supported = _check_module(manager, tuple(loaded.plugin for loaded in registry))
|
|
@@ -375,7 +396,7 @@ def analyze_project(
|
|
|
375
396
|
(a for plugin in all_plugins for a in plugin.advisories), file_advisories
|
|
376
397
|
)
|
|
377
398
|
affected = affected_symbols(dependencies, advisories)
|
|
378
|
-
models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
|
|
399
|
+
models = plugin_models(all_plugins, root).extended(*advisory_sinks(affected))
|
|
379
400
|
for manager in managers.values():
|
|
380
401
|
manager.provide(SecurityModelAnalysis, models)
|
|
381
402
|
manager.provide(DependencyAnalysis, dependencies)
|
|
@@ -397,10 +418,14 @@ def analyze_project(
|
|
|
397
418
|
for name in sorted(analysable):
|
|
398
419
|
for symbol, registered in _routes_of(analysable[name]).items():
|
|
399
420
|
routes.setdefault(symbol, registered)
|
|
421
|
+
escaped = escaped_templates(root)
|
|
422
|
+
clearing = models.clearing(escaped)
|
|
400
423
|
for manager in analysable.values():
|
|
401
424
|
manager.provide(RegisteredRoutes, routes)
|
|
425
|
+
manager.provide(EscapedTemplates, escaped)
|
|
426
|
+
manager.provide(ClearingAnalysis, clearing)
|
|
402
427
|
|
|
403
|
-
configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes)
|
|
428
|
+
configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes, escaped)
|
|
404
429
|
keys = module_keys(
|
|
405
430
|
graph,
|
|
406
431
|
{name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
|
|
@@ -441,6 +466,7 @@ def analyze_project(
|
|
|
441
466
|
encode_index(_seed(results, graph, component)),
|
|
442
467
|
advisory_paths,
|
|
443
468
|
_encode_routes(routes),
|
|
469
|
+
tuple(sorted(escaped)),
|
|
444
470
|
),
|
|
445
471
|
)
|
|
446
472
|
for component in pending
|
|
@@ -588,6 +614,7 @@ class _Batch:
|
|
|
588
614
|
seed: Mapping[str, Any]
|
|
589
615
|
advisory_paths: tuple[Path, ...] = ()
|
|
590
616
|
routes: tuple[tuple[str, str, str, int], ...] = ()
|
|
617
|
+
escaped: tuple[str, ...] = ()
|
|
591
618
|
|
|
592
619
|
|
|
593
620
|
def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
|
|
@@ -606,12 +633,15 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
|
|
|
606
633
|
(a for plugin in all_plugins for a in plugin.advisories), file_advisories
|
|
607
634
|
)
|
|
608
635
|
affected = affected_symbols(dependencies, advisories)
|
|
609
|
-
models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
|
|
636
|
+
models = plugin_models(all_plugins, batch.root).extended(*advisory_sinks(affected))
|
|
610
637
|
routes = _decode_routes(batch.routes)
|
|
638
|
+
escaped = frozenset(batch.escaped)
|
|
611
639
|
for manager in managers.values():
|
|
612
640
|
manager.provide(SecurityModelAnalysis, models)
|
|
613
641
|
manager.provide(DependencyAnalysis, dependencies)
|
|
614
642
|
manager.provide(RegisteredRoutes, routes)
|
|
643
|
+
manager.provide(EscapedTemplates, escaped)
|
|
644
|
+
manager.provide(ClearingAnalysis, models.clearing(escaped))
|
|
615
645
|
module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
|
|
616
646
|
results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
|
|
617
647
|
return {name: encode(entry) for name, entry in results.items()}
|
|
@@ -639,6 +669,7 @@ def _configuration_key(
|
|
|
639
669
|
advisories: tuple[Advisory, ...],
|
|
640
670
|
dependencies: DependencyGraph,
|
|
641
671
|
routes: Routes | None = None,
|
|
672
|
+
escaped: frozenset[str] = frozenset(),
|
|
642
673
|
) -> str:
|
|
643
674
|
"""Everything a module's results depend on besides the project sources (§11)."""
|
|
644
675
|
|
|
@@ -658,6 +689,7 @@ def _configuration_key(
|
|
|
658
689
|
repr(dependencies.requirements),
|
|
659
690
|
repr(dependencies.errors),
|
|
660
691
|
repr(_encode_routes(routes or {})),
|
|
692
|
+
repr(sorted(escaped)),
|
|
661
693
|
)
|
|
662
694
|
|
|
663
695
|
|
|
@@ -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
|
|
@@ -26,7 +29,12 @@ from coretrace_python.abstract import ConstantPropagation, RangeAnalysis, RangeF
|
|
|
26
29
|
from coretrace_python.analysis import AnalysisContext, AnyAnalysis, FunctionAnalysis
|
|
27
30
|
from coretrace_python.cfg import CFG, BlockId, CFGAnalysis, DominanceAnalysis, DominatorTree
|
|
28
31
|
from coretrace_python.hir import nodes
|
|
29
|
-
from coretrace_python.interprocedural import
|
|
32
|
+
from coretrace_python.interprocedural import (
|
|
33
|
+
CallGraph,
|
|
34
|
+
CallGraphAnalysis,
|
|
35
|
+
KnownFunction,
|
|
36
|
+
project_symbol,
|
|
37
|
+
)
|
|
30
38
|
from coretrace_python.ir.model import (
|
|
31
39
|
BoolOp,
|
|
32
40
|
Branch,
|
|
@@ -37,6 +45,7 @@ from coretrace_python.ir.model import (
|
|
|
37
45
|
Constant,
|
|
38
46
|
FunctionIR,
|
|
39
47
|
GetAttr,
|
|
48
|
+
GetItem,
|
|
40
49
|
Instruction,
|
|
41
50
|
UnaryOp,
|
|
42
51
|
Value,
|
|
@@ -51,6 +60,7 @@ from coretrace_python.taint import (
|
|
|
51
60
|
TaintAnalysis,
|
|
52
61
|
TaintFacts,
|
|
53
62
|
TaintFlow,
|
|
63
|
+
TaintKind,
|
|
54
64
|
)
|
|
55
65
|
|
|
56
66
|
VALIDATORS = frozenset(
|
|
@@ -210,15 +220,16 @@ class _Judge:
|
|
|
210
220
|
|
|
211
221
|
def interpret(
|
|
212
222
|
self, condition: Value, truth: bool | None
|
|
213
|
-
) -> tuple[dict[Value, str], set[Value]]:
|
|
214
|
-
"""Values the condition proves safe (with the reason
|
|
215
|
-
without proving anything. ``truth`` is ``None``
|
|
223
|
+
) -> tuple[dict[Value, tuple[str, TaintKind]], set[Value]]:
|
|
224
|
+
"""Values the condition proves safe (with the reason and the kinds it proves them
|
|
225
|
+
safe for), and values it mentions without proving anything. ``truth`` is ``None``
|
|
226
|
+
when it is not fixed at the sink.
|
|
216
227
|
|
|
217
228
|
A recognised check evaluated the wrong way (``isdigit()`` known false) yields
|
|
218
229
|
nothing: it is neither a proof nor a reassuring guard."""
|
|
219
230
|
|
|
220
231
|
definition = self.defs.get(condition)
|
|
221
|
-
proven: dict[Value, str] = {}
|
|
232
|
+
proven: dict[Value, tuple[str, TaintKind]] = {}
|
|
222
233
|
mentioned: set[Value] = set()
|
|
223
234
|
if isinstance(definition, UnaryOp) and definition.operator == "not":
|
|
224
235
|
return self.interpret(definition.operand, None if truth is None else not truth)
|
|
@@ -232,27 +243,29 @@ class _Judge:
|
|
|
232
243
|
recognised = self.recognise(definition)
|
|
233
244
|
if recognised is None:
|
|
234
245
|
return proven, set(self.closure(condition)) | {condition}
|
|
235
|
-
tested, reason, when_true = recognised
|
|
246
|
+
tested, reason, when_true, kinds = recognised
|
|
236
247
|
if truth is None:
|
|
237
248
|
mentioned = set(self.closure(condition)) | {condition}
|
|
238
249
|
elif truth == when_true:
|
|
239
250
|
# The check proves ``tested``; whatever else it reads is merely mentioned.
|
|
240
|
-
proven[tested] = reason
|
|
251
|
+
proven[tested] = (reason, kinds)
|
|
241
252
|
mentioned = set(self.closure(condition)) | {condition}
|
|
242
253
|
return proven, mentioned
|
|
243
254
|
|
|
244
|
-
def recognise(self, definition: Instruction | None) -> tuple[Value, str, bool] | None:
|
|
245
|
-
"""``(validated value, reason, truth that validates)`` for known
|
|
255
|
+
def recognise(self, definition: Instruction | None) -> tuple[Value, str, bool, TaintKind] | None:
|
|
256
|
+
"""``(validated value, reason, truth that validates, kinds it proves)`` for known
|
|
257
|
+
check shapes. A string check, a constant allowlist or an equality with a constant
|
|
258
|
+
proves every kind; a ``Validator`` only the kinds it declares."""
|
|
246
259
|
|
|
247
260
|
if isinstance(definition, Call) and not definition.arguments:
|
|
248
261
|
callee = self.defs.get(definition.callee)
|
|
249
262
|
if isinstance(callee, GetAttr) and callee.attribute in VALIDATORS:
|
|
250
|
-
return callee.object, f"guarded by {callee.attribute}()", True
|
|
263
|
+
return callee.object, f"guarded by {callee.attribute}()", True, TaintKind.ALL
|
|
251
264
|
if isinstance(definition, Call):
|
|
252
265
|
symbol = self.symbols.get(definition.callee)
|
|
253
266
|
validator = self.models.validator(symbol) if symbol is not None else None
|
|
254
267
|
if validator is not None and validator.argument < len(definition.arguments):
|
|
255
|
-
return definition.arguments[validator.argument], f"validated by {symbol}", True
|
|
268
|
+
return definition.arguments[validator.argument], f"validated by {symbol}", True, validator.kinds
|
|
256
269
|
if isinstance(definition, Compare):
|
|
257
270
|
left, right = definition.left, definition.right
|
|
258
271
|
if definition.operator in ("in", "not_in") and self.is_constant_collection(right):
|
|
@@ -260,11 +273,72 @@ class _Judge:
|
|
|
260
273
|
left,
|
|
261
274
|
"allowlisted by a membership check on constants",
|
|
262
275
|
definition.operator == "in",
|
|
276
|
+
TaintKind.ALL,
|
|
263
277
|
)
|
|
264
278
|
if definition.operator in ("eq", "not_eq"):
|
|
265
279
|
for tested, other in ((left, right), (right, left)):
|
|
266
280
|
if isinstance(self.defs.get(other), Constant):
|
|
267
|
-
return tested, "equals a constant", definition.operator == "eq"
|
|
281
|
+
return tested, "equals a constant", definition.operator == "eq", TaintKind.ALL
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
def examined(
|
|
285
|
+
self, condition: Value, mentioned: set[Value], argument: Value, chain: frozenset[Value]
|
|
286
|
+
) -> set[Value]:
|
|
287
|
+
"""What the guard examines of the values it mentions: everything its condition is
|
|
288
|
+
computed from, except an object it only reads another attribute or item of than
|
|
289
|
+
the flow reads (``request`` in ``request.method`` when the sink receives
|
|
290
|
+
``request.POST['cmd']``). A method called on the object (``form.is_valid()``), the
|
|
291
|
+
same attribute or item, or any part of an object the flow takes whole (passes to
|
|
292
|
+
the sink or to a call), examines it."""
|
|
293
|
+
|
|
294
|
+
reads = {self.access(value) for value in chain} - {None}
|
|
295
|
+
whole = {argument} | {
|
|
296
|
+
operand
|
|
297
|
+
for value in chain
|
|
298
|
+
if (definition := self.defs.get(value)) is not None
|
|
299
|
+
for operand in definition.operands()
|
|
300
|
+
if not (isinstance(definition, GetAttr | GetItem) and definition.object == operand)
|
|
301
|
+
}
|
|
302
|
+
found: set[Value] = set()
|
|
303
|
+
called: set[Value] = set()
|
|
304
|
+
pending = [condition]
|
|
305
|
+
while pending:
|
|
306
|
+
value = pending.pop()
|
|
307
|
+
if value in found:
|
|
308
|
+
continue
|
|
309
|
+
found.add(value)
|
|
310
|
+
definition = self.defs.get(value)
|
|
311
|
+
if definition is None:
|
|
312
|
+
continue
|
|
313
|
+
if isinstance(definition, Call):
|
|
314
|
+
called.add(definition.callee)
|
|
315
|
+
access = None if value in called else self.access(value)
|
|
316
|
+
for operand in definition.operands():
|
|
317
|
+
if access is not None and access[0] == operand and operand not in whole and self.aside(access, reads):
|
|
318
|
+
continue
|
|
319
|
+
pending.append(operand)
|
|
320
|
+
return found & mentioned
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def aside(access: tuple[Value, str, object], reads: set[tuple[Value, str, object] | None]) -> bool:
|
|
324
|
+
"""Whether reading ``access`` reads beside what the flow reads of the same object;
|
|
325
|
+
an item under a key unknown on either side may be the same one."""
|
|
326
|
+
|
|
327
|
+
owner, kind, name = access
|
|
328
|
+
if kind == "item" and (name is None or (owner, "item", None) in reads):
|
|
329
|
+
return False
|
|
330
|
+
return access not in reads
|
|
331
|
+
|
|
332
|
+
def access(self, value: Value) -> tuple[Value, str, object] | None:
|
|
333
|
+
"""``(object, "attribute" or "item", name or constant key)`` for a read of an
|
|
334
|
+
attribute or an item; ``None`` for any other value, and as the key when unknown."""
|
|
335
|
+
|
|
336
|
+
definition = self.defs.get(value)
|
|
337
|
+
if isinstance(definition, GetAttr):
|
|
338
|
+
return definition.object, "attribute", definition.attribute
|
|
339
|
+
if isinstance(definition, GetItem):
|
|
340
|
+
key = self.defs.get(definition.key)
|
|
341
|
+
return definition.object, "item", key.value if isinstance(key, Constant) else None
|
|
268
342
|
return None
|
|
269
343
|
|
|
270
344
|
def is_constant_collection(self, value: Value) -> bool:
|
|
@@ -323,11 +397,13 @@ class _Judge:
|
|
|
323
397
|
)
|
|
324
398
|
for guard in self.guards(sink):
|
|
325
399
|
found, mentioned = self.interpret(guard.condition, guard.truth)
|
|
326
|
-
for value, reason in found.items():
|
|
327
|
-
|
|
400
|
+
for value, (reason, kinds) in found.items():
|
|
401
|
+
# A check proving some kinds only leaves the others reaching the sink.
|
|
402
|
+
if value in chain and not flow.kinds & ~kinds:
|
|
328
403
|
proven.setdefault(value, reason)
|
|
404
|
+
examined = self.examined(guard.condition, mentioned, flow.argument, chain)
|
|
329
405
|
for origin in origins:
|
|
330
|
-
if origin not in mentions and
|
|
406
|
+
if origin not in mentions and examined & (
|
|
331
407
|
{origin} | {w for w in chain if origin in self.closure(w)}
|
|
332
408
|
):
|
|
333
409
|
mentions[origin] = guard.line
|
|
@@ -413,6 +489,7 @@ class RefutationAnalysis(FunctionAnalysis[Verdicts]):
|
|
|
413
489
|
constants = ctx.get(ConstantPropagation, function)
|
|
414
490
|
graph = ctx.get(CallGraphAnalysis)
|
|
415
491
|
models = ctx.get(SecurityModelAnalysis)
|
|
492
|
+
name = graph.name_of(function)
|
|
416
493
|
return judge_flows(
|
|
417
494
|
ssa,
|
|
418
495
|
ctx.get(CFGAnalysis, function),
|
|
@@ -421,7 +498,21 @@ class RefutationAnalysis(FunctionAnalysis[Verdicts]):
|
|
|
421
498
|
frozenset(b.id for b in ssa.blocks if constants.reachable(b.id)),
|
|
422
499
|
ctx.get(RangeAnalysis, function),
|
|
423
500
|
models,
|
|
424
|
-
|
|
501
|
+
_with_project_callees(ssa, graph, name, ctx.module.name),
|
|
425
502
|
authorization_of(function, models, ctx.get(ScopeAnalysis), ctx.get(SymbolAnalysis)),
|
|
426
503
|
)
|
|
427
504
|
|
|
505
|
+
|
|
506
|
+
def _with_project_callees(function: FunctionIR, graph: CallGraph, name: str, module: str) -> dict[Value, SymbolId]:
|
|
507
|
+
"""The symbols of ``name``'s values, with each call to a function of its module named
|
|
508
|
+
by its project symbol, so a model declared on it (``Validator``) applies there too."""
|
|
509
|
+
|
|
510
|
+
symbols = dict(graph.symbols(name))
|
|
511
|
+
for block in function.blocks:
|
|
512
|
+
for instruction in block.instructions:
|
|
513
|
+
if isinstance(instruction, Call):
|
|
514
|
+
target = graph.target_at(name, instruction.location)
|
|
515
|
+
if isinstance(target, KnownFunction):
|
|
516
|
+
symbols.setdefault(instruction.callee, project_symbol(module, target.name))
|
|
517
|
+
return symbols
|
|
518
|
+
|
|
@@ -20,6 +20,9 @@ from coretrace_python.interprocedural.modulegraph import (
|
|
|
20
20
|
project_symbol,
|
|
21
21
|
)
|
|
22
22
|
from coretrace_python.interprocedural.summaries import (
|
|
23
|
+
Cleared,
|
|
24
|
+
Clearing,
|
|
25
|
+
ClearingAnalysis,
|
|
23
26
|
ExternalCall,
|
|
24
27
|
FunctionSummary,
|
|
25
28
|
Mutation,
|
|
@@ -28,6 +31,7 @@ from coretrace_python.interprocedural.summaries import (
|
|
|
28
31
|
SummaryAnalysis,
|
|
29
32
|
SummaryIndex,
|
|
30
33
|
SummaryTable,
|
|
34
|
+
cleared_by,
|
|
31
35
|
)
|
|
32
36
|
|
|
33
37
|
__all__ = [
|
|
@@ -35,6 +39,9 @@ __all__ = [
|
|
|
35
39
|
"CallGraph",
|
|
36
40
|
"CallGraphAnalysis",
|
|
37
41
|
"CallSite",
|
|
42
|
+
"Cleared",
|
|
43
|
+
"Clearing",
|
|
44
|
+
"ClearingAnalysis",
|
|
38
45
|
"ExternalCall",
|
|
39
46
|
"ExternalSymbol",
|
|
40
47
|
"FunctionSummary",
|
|
@@ -51,6 +58,7 @@ __all__ = [
|
|
|
51
58
|
"Target",
|
|
52
59
|
"UnknownTarget",
|
|
53
60
|
"build_module_graph",
|
|
61
|
+
"cleared_by",
|
|
54
62
|
"discover_files",
|
|
55
63
|
"discover_sources",
|
|
56
64
|
"project_symbol",
|