coretrace-python-analyzer 0.22.0__py3-none-any.whl → 0.23.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/abstract/module_constants.py +121 -0
- coretrace_python/abstract/strings.py +12 -42
- coretrace_python/bundled/models/python_stdlib/python_stdlib.py +27 -0
- coretrace_python/bundled/secrets/config_secrets/config_secrets.py +2 -1
- coretrace_python/bundled/security/code_injection/code_injection.py +85 -4
- coretrace_python/bundled/security/code_injection/plugin.toml +1 -1
- coretrace_python/bundled/syntax/unsafe_xml/plugin.toml +1 -1
- coretrace_python/bundled/syntax/unsafe_xml/unsafe_xml.py +92 -12
- coretrace_python/cache.py +14 -14
- coretrace_python/cli.py +33 -5
- coretrace_python/engine.py +197 -60
- coretrace_python/findings/context.py +57 -0
- coretrace_python/findings/refutation.py +210 -27
- coretrace_python/frontend/parser.py +7 -1
- coretrace_python/interprocedural/callgraph.py +14 -1
- coretrace_python/interprocedural/summaries.py +91 -19
- coretrace_python/ir/defuse.py +26 -1
- coretrace_python/ir/lowering.py +2 -1
- coretrace_python/ir/model.py +5 -2
- coretrace_python/ir/printer.py +4 -1
- coretrace_python/plugins/__init__.py +7 -1
- coretrace_python/plugins/api.py +16 -3
- coretrace_python/plugins/detectors.py +21 -3
- coretrace_python/plugins/secrets.py +17 -11
- coretrace_python/reporters/sarif.py +15 -3
- coretrace_python/semantic/symbols.py +2 -1
- coretrace_python/taint/engine.py +114 -27
- coretrace_python/taint/models.py +27 -3
- {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/METADATA +1 -1
- {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/RECORD +35 -33
- {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/WHEEL +0 -0
- {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/entry_points.txt +0 -0
- {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/licenses/LICENSE +0 -0
- {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/licenses/NOTICE +0 -0
coretrace_python/__init__.py
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Module-level names whose value is known (architecture §18): the names bound once and
|
|
2
|
+
never rebound, and among them the ones bound to an immutable collection of constants,
|
|
3
|
+
which an allowlist reads."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis
|
|
12
|
+
from coretrace_python.cfg import CFGError
|
|
13
|
+
from coretrace_python.hir import nodes
|
|
14
|
+
from coretrace_python.ir.lowering import (
|
|
15
|
+
MODULE_BODY,
|
|
16
|
+
LoweringError,
|
|
17
|
+
PyIRAnalysis,
|
|
18
|
+
analyzable_functions,
|
|
19
|
+
)
|
|
20
|
+
from coretrace_python.ir.model import (
|
|
21
|
+
BuildDict,
|
|
22
|
+
BuildList,
|
|
23
|
+
BuildSet,
|
|
24
|
+
BuildTuple,
|
|
25
|
+
Call,
|
|
26
|
+
Constant,
|
|
27
|
+
ForNext,
|
|
28
|
+
Instruction,
|
|
29
|
+
SetGlobal,
|
|
30
|
+
StoreLocal,
|
|
31
|
+
Symbol,
|
|
32
|
+
Value,
|
|
33
|
+
)
|
|
34
|
+
from coretrace_python.semantic.symbols import SymbolId
|
|
35
|
+
|
|
36
|
+
FROZENSET = SymbolId("python.builtins.frozenset")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def module_bindings(ctx: AnalysisContext) -> tuple[Mapping[str, Instruction], Mapping[Value, Instruction]]:
|
|
40
|
+
"""The module-level names bound once and never rebound, by no other statement of the
|
|
41
|
+
module body, no function assigning them as ``global``, no definition of that name,
|
|
42
|
+
each with the instruction that makes its value; and the definitions of the module
|
|
43
|
+
body's values. A module that does not lower entirely has none."""
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
functions = [ctx.get(PyIRAnalysis, function) for function in analyzable_functions(ctx.module)]
|
|
47
|
+
except (LoweringError, CFGError):
|
|
48
|
+
return MappingProxyType({}), MappingProxyType({})
|
|
49
|
+
body = next((function for function in functions if function.name == MODULE_BODY), None)
|
|
50
|
+
if body is None:
|
|
51
|
+
return MappingProxyType({}), MappingProxyType({})
|
|
52
|
+
defs = {i.result: i for block in body.blocks for i in block.instructions if i.result is not None}
|
|
53
|
+
stored: dict[str, list[Value | None]] = {}
|
|
54
|
+
for block in body.blocks:
|
|
55
|
+
for instruction in block.instructions:
|
|
56
|
+
if isinstance(instruction, StoreLocal):
|
|
57
|
+
stored.setdefault(instruction.name, []).append(instruction.value)
|
|
58
|
+
if isinstance(block.terminator, ForNext):
|
|
59
|
+
# A ``for`` binds its target to each item.
|
|
60
|
+
stored.setdefault(block.terminator.target, []).append(None)
|
|
61
|
+
rebound = {
|
|
62
|
+
i.name for function in functions for block in function.blocks for i in block.instructions
|
|
63
|
+
if isinstance(i, SetGlobal)
|
|
64
|
+
}
|
|
65
|
+
rebound |= {s.name for s in ctx.module.body if isinstance(s, nodes.Function | nodes.Class)}
|
|
66
|
+
bound: dict[str, Instruction] = {}
|
|
67
|
+
for name, values in stored.items():
|
|
68
|
+
value = values[0] if len(values) == 1 and name not in rebound else None
|
|
69
|
+
made = defs.get(value) if value is not None else None
|
|
70
|
+
if made is not None:
|
|
71
|
+
bound[name] = made
|
|
72
|
+
return MappingProxyType(bound), MappingProxyType(defs)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def constant_members(display: Instruction | None, defs: Mapping[Value, Instruction]) -> bool:
|
|
76
|
+
"""Whether ``display`` is a tuple, list or set display whose items are all constants,
|
|
77
|
+
or a dict display whose keys are, with nothing unpacked into it: the members a
|
|
78
|
+
membership test reads."""
|
|
79
|
+
|
|
80
|
+
if isinstance(display, BuildList | BuildTuple | BuildSet):
|
|
81
|
+
return not display.unpacked and all(isinstance(defs.get(e), Constant) for e in display.elements)
|
|
82
|
+
if isinstance(display, BuildDict):
|
|
83
|
+
return not display.unpacked and all(isinstance(defs.get(key), Constant) for key, _ in display.items)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def converts(call: Call, defs: Mapping[Value, Instruction], symbols: frozenset[SymbolId]) -> Value | None:
|
|
88
|
+
"""The one positional argument of ``call`` when it calls one of ``symbols`` with
|
|
89
|
+
nothing else (``frozenset(x)``), otherwise ``None``."""
|
|
90
|
+
|
|
91
|
+
callee = defs.get(call.callee)
|
|
92
|
+
if not isinstance(callee, Symbol) or callee.symbol_id not in symbols:
|
|
93
|
+
return None
|
|
94
|
+
if len(call.arguments) != 1 or call.keywords or call.starred:
|
|
95
|
+
return None
|
|
96
|
+
return call.arguments[0]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ModuleCollectionsAnalysis(Analysis[frozenset[str]]):
|
|
100
|
+
"""The module-level names bound once and never rebound (``module_bindings``) to an
|
|
101
|
+
immutable collection of constants: a tuple display, or ``frozenset`` of a display
|
|
102
|
+
whose members are constants (``constant_members``). A list, a set or a dict can still
|
|
103
|
+
change after it is bound, so it is not one."""
|
|
104
|
+
|
|
105
|
+
name: ClassVar[str] = "abstract.module_collections"
|
|
106
|
+
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({PyIRAnalysis})
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def compute(cls, ctx: AnalysisContext) -> frozenset[str]:
|
|
110
|
+
bound, defs = module_bindings(ctx)
|
|
111
|
+
found: set[str] = set()
|
|
112
|
+
for name, made in bound.items():
|
|
113
|
+
collection: Instruction | None = made
|
|
114
|
+
if isinstance(made, Call):
|
|
115
|
+
argument = converts(made, defs, frozenset({FROZENSET}))
|
|
116
|
+
collection = defs.get(argument) if argument is not None else None
|
|
117
|
+
elif not isinstance(made, BuildTuple):
|
|
118
|
+
continue
|
|
119
|
+
if constant_members(collection, defs):
|
|
120
|
+
found.add(name)
|
|
121
|
+
return frozenset(found)
|
|
@@ -9,24 +9,15 @@ from collections.abc import Mapping
|
|
|
9
9
|
from types import MappingProxyType
|
|
10
10
|
from typing import ClassVar
|
|
11
11
|
|
|
12
|
+
from coretrace_python.abstract.module_constants import module_bindings
|
|
12
13
|
from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis
|
|
13
|
-
from coretrace_python.
|
|
14
|
-
from coretrace_python.hir import nodes
|
|
15
|
-
from coretrace_python.ir.lowering import (
|
|
16
|
-
MODULE_BODY,
|
|
17
|
-
LoweringError,
|
|
18
|
-
PyIRAnalysis,
|
|
19
|
-
analyzable_functions,
|
|
20
|
-
)
|
|
14
|
+
from coretrace_python.ir.lowering import PyIRAnalysis
|
|
21
15
|
from coretrace_python.ir.model import (
|
|
22
16
|
BinaryOp,
|
|
23
17
|
BuildString,
|
|
24
18
|
Constant,
|
|
25
|
-
ForNext,
|
|
26
19
|
Global,
|
|
27
20
|
Instruction,
|
|
28
|
-
SetGlobal,
|
|
29
|
-
StoreLocal,
|
|
30
21
|
Value,
|
|
31
22
|
)
|
|
32
23
|
|
|
@@ -59,40 +50,19 @@ def leading_text(value: Value, defs: Mapping[Value, Instruction], strings: Mappi
|
|
|
59
50
|
|
|
60
51
|
|
|
61
52
|
class ModuleStringsAnalysis(Analysis[Mapping[str, str]]):
|
|
62
|
-
"""The module-level names bound once
|
|
63
|
-
|
|
64
|
-
definition of that name. A module that does not lower entirely has none."""
|
|
53
|
+
"""The module-level names bound once and never rebound (``module_bindings``) to a
|
|
54
|
+
string literal, with that literal."""
|
|
65
55
|
|
|
66
56
|
name: ClassVar[str] = "abstract.module_strings"
|
|
67
57
|
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({PyIRAnalysis})
|
|
68
58
|
|
|
69
59
|
@classmethod
|
|
70
60
|
def compute(cls, ctx: AnalysisContext) -> Mapping[str, str]:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
stored: dict[str, list[Value | None]] = {}
|
|
80
|
-
for block in body.blocks:
|
|
81
|
-
for instruction in block.instructions:
|
|
82
|
-
if isinstance(instruction, StoreLocal):
|
|
83
|
-
stored.setdefault(instruction.name, []).append(instruction.value)
|
|
84
|
-
if isinstance(block.terminator, ForNext):
|
|
85
|
-
# A ``for`` binds its target to each item.
|
|
86
|
-
stored.setdefault(block.terminator.target, []).append(None)
|
|
87
|
-
rebound = {
|
|
88
|
-
i.name for function in functions for block in function.blocks for i in block.instructions
|
|
89
|
-
if isinstance(i, SetGlobal)
|
|
90
|
-
}
|
|
91
|
-
rebound |= {s.name for s in ctx.module.body if isinstance(s, nodes.Function | nodes.Class)}
|
|
92
|
-
found: dict[str, str] = {}
|
|
93
|
-
for name, values in stored.items():
|
|
94
|
-
value = values[0] if len(values) == 1 and name not in rebound else None
|
|
95
|
-
made = defs.get(value) if value is not None else None
|
|
96
|
-
if isinstance(made, Constant) and isinstance(made.value, str):
|
|
97
|
-
found[name] = made.value
|
|
98
|
-
return MappingProxyType(found)
|
|
61
|
+
bound, _ = module_bindings(ctx)
|
|
62
|
+
return MappingProxyType(
|
|
63
|
+
{
|
|
64
|
+
name: made.value
|
|
65
|
+
for name, made in bound.items()
|
|
66
|
+
if isinstance(made, Constant) and isinstance(made.value, str)
|
|
67
|
+
}
|
|
68
|
+
)
|
|
@@ -62,6 +62,33 @@ class PythonStdlibModels(ModelPlugin):
|
|
|
62
62
|
# The code is the first argument; the globals and locals it runs with are data.
|
|
63
63
|
Sink(_sym("builtins.eval"), TaintKind.CODE, ((TaintKind.CODE, (0,)),)),
|
|
64
64
|
Sink(_sym("builtins.exec"), TaintKind.CODE, ((TaintKind.CODE, (0,)),)),
|
|
65
|
+
# The module or the file the call imports or runs; its globals, its run name and
|
|
66
|
+
# the package of a relative name are data (#240).
|
|
67
|
+
*(
|
|
68
|
+
Sink(_sym(symbol), TaintKind.CODE, ((TaintKind.CODE, (0,)),), ((TaintKind.CODE, (keyword,)),), selects=True)
|
|
69
|
+
for symbol, keyword in (
|
|
70
|
+
("importlib.import_module", "name"),
|
|
71
|
+
("builtins.__import__", "name"),
|
|
72
|
+
("importlib.__import__", "name"),
|
|
73
|
+
("runpy.run_module", "mod_name"),
|
|
74
|
+
("runpy.run_path", "path_name"),
|
|
75
|
+
)
|
|
76
|
+
),
|
|
77
|
+
# The directory imports are looked up in, and the site directory whose ``.pth``
|
|
78
|
+
# files run; the index of ``insert`` is data (#240).
|
|
79
|
+
Sink(_sym("sys.path.insert"), TaintKind.CODE, ((TaintKind.CODE, (1,)),), selects=True),
|
|
80
|
+
Sink(_sym("sys.path.append"), TaintKind.CODE, ((TaintKind.CODE, (0,)),), selects=True),
|
|
81
|
+
Sink(_sym("sys.path.extend"), TaintKind.CODE, ((TaintKind.CODE, (0,)),), selects=True),
|
|
82
|
+
Sink(_sym("site.addsitedir"), TaintKind.CODE, ((TaintKind.CODE, (0,)),), ((TaintKind.CODE, ("sitedir",)),), selects=True),
|
|
83
|
+
# The name of the attribute ``getattr`` selects, when what it selects is called;
|
|
84
|
+
# the object and the default are data (#240).
|
|
85
|
+
Sink(
|
|
86
|
+
_sym("builtins.getattr"),
|
|
87
|
+
TaintKind.CODE,
|
|
88
|
+
((TaintKind.CODE, (1,)),),
|
|
89
|
+
selects=True,
|
|
90
|
+
requires_called_result=TaintKind.CODE,
|
|
91
|
+
),
|
|
65
92
|
Sink(_sym("builtins.open"), TaintKind.PATH),
|
|
66
93
|
Sink(_sym("os.remove"), TaintKind.PATH),
|
|
67
94
|
Sink(_sym("os.unlink"), TaintKind.PATH),
|
|
@@ -9,6 +9,7 @@ from collections.abc import Sequence
|
|
|
9
9
|
from typing import ClassVar
|
|
10
10
|
|
|
11
11
|
from coretrace_python.findings import Finding
|
|
12
|
+
from coretrace_python.findings.context import path_in_project
|
|
12
13
|
from coretrace_python.plugins import PluginContext, ProjectContext, ProjectPlugin
|
|
13
14
|
from coretrace_python.plugins.secrets import (
|
|
14
15
|
DEFAULT_CREDENTIAL_NAMES,
|
|
@@ -32,7 +33,7 @@ class ConfigSecrets(SecretDetector, ProjectPlugin):
|
|
|
32
33
|
return ()
|
|
33
34
|
findings: list[Finding] = []
|
|
34
35
|
for value, name, span, function in config_literals(ctx.root):
|
|
35
|
-
finding = self.judge(value, name, span, function)
|
|
36
|
+
finding = self.judge(value, name, span, function, path=path_in_project(span.source_id, ctx.root))
|
|
36
37
|
if finding is not None:
|
|
37
38
|
findings.append(finding)
|
|
38
39
|
return findings
|
|
@@ -1,14 +1,43 @@
|
|
|
1
1
|
"""Code injection: attacker-controlled input reaching a CODE sink, such as the code
|
|
2
|
-
``eval`` or ``exec`` runs. ``
|
|
3
|
-
the
|
|
2
|
+
``eval`` or ``exec`` runs, the module ``importlib.import_module``, ``__import__`` or
|
|
3
|
+
``runpy.run_module`` imports, the file ``runpy.run_path`` runs, the directory
|
|
4
|
+
``sys.path`` and ``site.addsitedir`` load code from, or the attribute ``getattr`` selects
|
|
5
|
+
when it is called. ``dangerous-eval`` reports every ``eval`` or ``exec``; this rule reports
|
|
6
|
+
the calls attacker input reaches.
|
|
7
|
+
|
|
8
|
+
An import runs the top-level code of a module the input chooses among those that can be
|
|
9
|
+
found, and a script path runs the file the input chooses: execution is possible, and
|
|
10
|
+
how far it goes depends on what the input can reach. Their findings are titled for
|
|
11
|
+
that. A constant package prefix (``"app.plugins." + name``) confines an import to the
|
|
12
|
+
modules of that package: a hotspot. A directory added to ``sys.path`` runs code only
|
|
13
|
+
when a later import resolves there: a hotspot. ``site.addsitedir`` runs the import lines
|
|
14
|
+
of the directory's ``.pth`` files during the call: a vulnerability, exploitable when the
|
|
15
|
+
directory holds files the attacker controls, as the finding states. The attribute
|
|
16
|
+
``getattr`` selects runs at each call of its result, where the finding is placed: what
|
|
17
|
+
runs depends on what the object exposes, a hotspot."""
|
|
4
18
|
|
|
5
19
|
from __future__ import annotations
|
|
6
20
|
|
|
7
21
|
from typing import ClassVar
|
|
8
22
|
|
|
23
|
+
from coretrace_python.abstract.strings import ModuleStringsAnalysis, leading_text
|
|
24
|
+
from coretrace_python.analysis import AnyAnalysis
|
|
9
25
|
from coretrace_python.findings import Severity
|
|
10
|
-
from coretrace_python.
|
|
11
|
-
from coretrace_python.
|
|
26
|
+
from coretrace_python.findings.refutation import Status, Verdict
|
|
27
|
+
from coretrace_python.hir import nodes
|
|
28
|
+
from coretrace_python.ir.ssa import SSAAnalysis
|
|
29
|
+
from coretrace_python.plugins import Assessment, PluginContext, TaintDetector
|
|
30
|
+
from coretrace_python.semantic.symbols import SymbolId
|
|
31
|
+
from coretrace_python.taint import TaintFlow, TaintKind
|
|
32
|
+
|
|
33
|
+
_IMPORTS = frozenset(
|
|
34
|
+
SymbolId(f"python.{name}")
|
|
35
|
+
for name in ("importlib.import_module", "builtins.__import__", "importlib.__import__", "runpy.run_module")
|
|
36
|
+
)
|
|
37
|
+
_RUN_PATH = SymbolId("python.runpy.run_path")
|
|
38
|
+
_IMPORT_PATHS = frozenset(SymbolId(f"python.sys.path.{name}") for name in ("insert", "append", "extend"))
|
|
39
|
+
_SITE_DIRECTORY = SymbolId("python.site.addsitedir")
|
|
40
|
+
_GETATTR = SymbolId("python.builtins.getattr")
|
|
12
41
|
|
|
13
42
|
|
|
14
43
|
class CodeInjectionPlugin(TaintDetector):
|
|
@@ -17,3 +46,55 @@ class CodeInjectionPlugin(TaintDetector):
|
|
|
17
46
|
kind: ClassVar[TaintKind] = TaintKind.CODE
|
|
18
47
|
severity: ClassVar[Severity] = Severity.HIGH
|
|
19
48
|
title: ClassVar[str] = "Code injection"
|
|
49
|
+
requires: ClassVar[frozenset[AnyAnalysis]] = TaintDetector.requires | {SSAAnalysis, ModuleStringsAnalysis}
|
|
50
|
+
|
|
51
|
+
def assess(
|
|
52
|
+
self, ctx: PluginContext, function: nodes.Function, flow: TaintFlow, verdict: Verdict
|
|
53
|
+
) -> Assessment:
|
|
54
|
+
symbol = flow.sink.symbol
|
|
55
|
+
if symbol == _RUN_PATH:
|
|
56
|
+
return Assessment(verdict, self.severity, "Untrusted script execution")
|
|
57
|
+
if symbol in _IMPORT_PATHS:
|
|
58
|
+
if verdict.status is Status.VULNERABILITY:
|
|
59
|
+
verdict = Verdict(
|
|
60
|
+
flow,
|
|
61
|
+
Status.HOTSPOT,
|
|
62
|
+
"the input chooses a directory imports are looked up in: code runs only when a later "
|
|
63
|
+
"import resolves to a module the attacker placed there",
|
|
64
|
+
)
|
|
65
|
+
return Assessment(verdict, self.severity, "Untrusted import path")
|
|
66
|
+
if symbol == _GETATTR:
|
|
67
|
+
if verdict.status is Status.VULNERABILITY:
|
|
68
|
+
verdict = Verdict(
|
|
69
|
+
flow,
|
|
70
|
+
Status.HOTSPOT,
|
|
71
|
+
"the input chooses the attribute called: what runs depends on what the object exposes",
|
|
72
|
+
)
|
|
73
|
+
lines: dict[str, str] = {}
|
|
74
|
+
if flow.invoked_at is not None and flow.sink_location is not None:
|
|
75
|
+
lines = {
|
|
76
|
+
"selected_line": str(flow.sink_location.start_line),
|
|
77
|
+
"invoked_line": str(flow.invoked_at.start_line),
|
|
78
|
+
}
|
|
79
|
+
return Assessment(verdict, self.severity, "Untrusted attribute call", lines)
|
|
80
|
+
if symbol == _SITE_DIRECTORY:
|
|
81
|
+
if verdict.status is Status.VULNERABILITY:
|
|
82
|
+
verdict = Verdict(
|
|
83
|
+
flow,
|
|
84
|
+
Status.VULNERABILITY,
|
|
85
|
+
f"{verdict.evidence}; the import lines of the .pth files of the directory run during the "
|
|
86
|
+
"call: exploitable when the input chooses a directory holding files the attacker controls",
|
|
87
|
+
)
|
|
88
|
+
return Assessment(verdict, self.severity, "Untrusted site directory")
|
|
89
|
+
if symbol not in _IMPORTS:
|
|
90
|
+
return Assessment(verdict, self.severity)
|
|
91
|
+
if verdict.status is Status.VULNERABILITY and flow.through is None:
|
|
92
|
+
ssa = ctx.get(SSAAnalysis, function)
|
|
93
|
+
defs = {i.result: i for block in ssa.blocks for i in block.instructions if i.result is not None}
|
|
94
|
+
prefix, whole = leading_text(flow.argument, defs, ctx.get(ModuleStringsAnalysis))
|
|
95
|
+
package = prefix.rpartition(".")[0]
|
|
96
|
+
if not whole and package and not prefix.startswith("."):
|
|
97
|
+
verdict = Verdict(
|
|
98
|
+
flow, Status.HOTSPOT, f"the package is fixed by {prefix!r}, but the input chooses a module in it"
|
|
99
|
+
)
|
|
100
|
+
return Assessment(verdict, self.severity, "Untrusted module import")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
name = "code-injection"
|
|
2
2
|
version = "1.0.0"
|
|
3
3
|
plugin_api = ">=1,<2"
|
|
4
|
-
requires = ["taint.flows", "findings.refutation"]
|
|
4
|
+
requires = ["taint.flows", "findings.refutation", "ir.ssa", "abstract.module_strings"]
|
|
5
5
|
provides = ["vulnerability.code-injection"]
|
|
6
6
|
|
|
7
7
|
[entrypoint]
|
|
@@ -1,35 +1,115 @@
|
|
|
1
|
-
"""XML parsers of the standard library and lxml
|
|
2
|
-
|
|
1
|
+
"""XML parsers of the standard library and of lxml, each reported for what it risks.
|
|
2
|
+
|
|
3
|
+
The standard library's parsers run on Expat, which never reads a local file or opens a
|
|
4
|
+
connection, but expands the entities a document declares: a crafted document can exhaust
|
|
5
|
+
memory with Expat older than 2.7.2, a fact of run time (``pyexpat.EXPAT_VERSION``). lxml
|
|
6
|
+
resolves external entities by default before 5.0, and in ``iterparse`` and
|
|
7
|
+
``ETCompatXMLParser`` before 6.1 (CVE-2026-41066), which lets a crafted document read
|
|
8
|
+
local files; ``resolve_entities=True`` does so whatever the version, ``False`` never.
|
|
9
|
+
``'internal'`` keeps external entities out only from lxml 5.0 on: an earlier lxml reads
|
|
10
|
+
the string as true. ``defusedxml`` offers the same functions safely. XML content the
|
|
11
|
+
project wrote itself, a string constant or a module-level string bound once given to a
|
|
12
|
+
function that parses its argument as the document, is no attacker's: its finding is kept
|
|
13
|
+
at low confidence. A path is not the document: whoever can write the file chooses it."""
|
|
3
14
|
|
|
4
15
|
from __future__ import annotations
|
|
5
16
|
|
|
17
|
+
from collections.abc import Mapping
|
|
6
18
|
from typing import ClassVar
|
|
7
19
|
|
|
20
|
+
from coretrace_python.abstract.strings import ModuleStringsAnalysis
|
|
8
21
|
from coretrace_python.analysis import AnyAnalysis
|
|
9
|
-
from coretrace_python.findings import Severity
|
|
22
|
+
from coretrace_python.findings import Confidence, Severity
|
|
10
23
|
from coretrace_python.interprocedural import CallGraphAnalysis
|
|
24
|
+
from coretrace_python.ir.model import Call, Constant, Global, Instruction, Value
|
|
11
25
|
from coretrace_python.ir.ssa import SSAAnalysis
|
|
12
|
-
from coretrace_python.plugins import SymbolCallDetector
|
|
26
|
+
from coretrace_python.plugins import CallReport, PluginContext, SymbolCallDetector
|
|
13
27
|
from coretrace_python.semantic.symbols import SymbolId
|
|
14
28
|
|
|
15
|
-
|
|
29
|
+
|
|
30
|
+
def _symbols(*paths: str) -> frozenset[SymbolId]:
|
|
31
|
+
return frozenset(SymbolId(f"python.{path}") for path in paths)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_STANDARD_LIBRARY = _symbols(
|
|
16
35
|
"xml.etree.ElementTree.parse", "xml.etree.ElementTree.fromstring", "xml.etree.ElementTree.iterparse",
|
|
17
36
|
"xml.etree.ElementTree.XMLParser", "xml.etree.ElementTree.fromstringlist",
|
|
18
37
|
"xml.dom.minidom.parse", "xml.dom.minidom.parseString",
|
|
19
38
|
"xml.dom.pulldom.parse", "xml.dom.pulldom.parseString",
|
|
20
39
|
"xml.sax.parse", "xml.sax.parseString", "xml.sax.make_parser",
|
|
21
40
|
"xml.dom.expatbuilder.parse", "xml.dom.expatbuilder.parseString",
|
|
22
|
-
"lxml.etree.parse", "lxml.etree.fromstring", "lxml.etree.XMLParser", "lxml.etree.iterparse",
|
|
23
|
-
"lxml.etree.XML",
|
|
24
41
|
)
|
|
42
|
+
_LXML = _symbols("lxml.etree.parse", "lxml.etree.fromstring", "lxml.etree.XMLParser", "lxml.etree.XML")
|
|
43
|
+
# Their default kept ``resolve_entities=True`` until lxml 6.1 (CVE-2026-41066).
|
|
44
|
+
_LXML_UNTIL_6_1 = _symbols("lxml.etree.iterparse", "lxml.etree.ETCompatXMLParser")
|
|
45
|
+
# The functions whose first argument is the XML itself, not a file, a URL or a stream.
|
|
46
|
+
_CONTENT = _symbols(
|
|
47
|
+
"xml.etree.ElementTree.fromstring", "xml.etree.ElementTree.fromstringlist",
|
|
48
|
+
"xml.dom.minidom.parseString", "xml.dom.pulldom.parseString", "xml.sax.parseString",
|
|
49
|
+
"xml.dom.expatbuilder.parseString", "lxml.etree.fromstring", "lxml.etree.XML",
|
|
50
|
+
)
|
|
51
|
+
# A keyword that is not a constant: its value is unknown.
|
|
52
|
+
_UNKNOWN = object()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_entities(call: Call, defs: Mapping[Value, Instruction]) -> object:
|
|
56
|
+
"""The constant ``resolve_entities=`` of ``call``, ``None`` when it is not passed."""
|
|
57
|
+
|
|
58
|
+
for name, value in call.keywords:
|
|
59
|
+
if name == "resolve_entities":
|
|
60
|
+
made = defs.get(value)
|
|
61
|
+
return made.value if isinstance(made, Constant) else _UNKNOWN
|
|
62
|
+
return None
|
|
25
63
|
|
|
26
64
|
|
|
27
65
|
class UnsafeXmlPlugin(SymbolCallDetector):
|
|
28
66
|
name: ClassVar[str] = "unsafe-xml"
|
|
29
67
|
rule_id: ClassVar[str] = "unsafe-xml"
|
|
30
|
-
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, CallGraphAnalysis})
|
|
31
|
-
symbols: ClassVar[frozenset[SymbolId]] =
|
|
68
|
+
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({SSAAnalysis, CallGraphAnalysis, ModuleStringsAnalysis})
|
|
69
|
+
symbols: ClassVar[frozenset[SymbolId]] = _STANDARD_LIBRARY | _LXML | _LXML_UNTIL_6_1
|
|
32
70
|
severity: ClassVar[Severity] = Severity.MEDIUM
|
|
33
|
-
message_template: ClassVar[str] =
|
|
34
|
-
|
|
35
|
-
)
|
|
71
|
+
message_template: ClassVar[str] = "call to {symbol} parses XML unsafely; use defusedxml"
|
|
72
|
+
|
|
73
|
+
def accepts(self, call: Call, symbol: SymbolId, defs: Mapping[Value, Instruction]) -> bool:
|
|
74
|
+
# ``resolve_entities`` is an lxml option; the standard library has none.
|
|
75
|
+
return _resolve_entities(call, defs) is not False
|
|
76
|
+
|
|
77
|
+
def describe(
|
|
78
|
+
self, ctx: PluginContext, call: Call, symbol: SymbolId, defs: Mapping[Value, Instruction]
|
|
79
|
+
) -> CallReport:
|
|
80
|
+
if symbol in _STANDARD_LIBRARY:
|
|
81
|
+
risk = "expands the entities a document declares, which can exhaust memory with Expat older than 2.7.2"
|
|
82
|
+
advice = "use defusedxml"
|
|
83
|
+
elif _resolve_entities(call, defs) is True:
|
|
84
|
+
risk = "resolves external entities (resolve_entities=True), which lets a crafted document read local files"
|
|
85
|
+
advice = "use defusedxml"
|
|
86
|
+
elif _resolve_entities(call, defs) == "internal":
|
|
87
|
+
risk = (
|
|
88
|
+
"sets resolve_entities='internal', which keeps external entities out only from lxml 5.0 on: "
|
|
89
|
+
"before lxml 5.0 the string reads as true and external entities are resolved, which lets a "
|
|
90
|
+
"crafted document read local files"
|
|
91
|
+
)
|
|
92
|
+
advice = "use defusedxml or resolve_entities=False"
|
|
93
|
+
elif symbol in _LXML_UNTIL_6_1:
|
|
94
|
+
risk = (
|
|
95
|
+
"resolves external entities by default before lxml 6.1 (CVE-2026-41066), "
|
|
96
|
+
"which lets a crafted document read local files"
|
|
97
|
+
)
|
|
98
|
+
advice = "use defusedxml or resolve_entities=False"
|
|
99
|
+
else:
|
|
100
|
+
risk = "resolves external entities by default before lxml 5.0, which lets a crafted document read local files"
|
|
101
|
+
advice = "use defusedxml"
|
|
102
|
+
written = symbol in _CONTENT and self.written_by_the_project(ctx, call, defs)
|
|
103
|
+
confidence = Confidence.LOW if written else self.confidence
|
|
104
|
+
return CallReport(f"call to {symbol} {risk}; {advice}", confidence)
|
|
105
|
+
|
|
106
|
+
def written_by_the_project(self, ctx: PluginContext, call: Call, defs: Mapping[Value, Instruction]) -> bool:
|
|
107
|
+
"""Whether the first argument of ``call``, which a ``_CONTENT`` function parses as
|
|
108
|
+
the document, is a string constant or a module-level name bound once to one."""
|
|
109
|
+
|
|
110
|
+
if not call.arguments:
|
|
111
|
+
return False
|
|
112
|
+
made = defs.get(call.arguments[0])
|
|
113
|
+
if isinstance(made, Constant):
|
|
114
|
+
return isinstance(made.value, str | bytes)
|
|
115
|
+
return isinstance(made, Global) and made.name in ctx.get(ModuleStringsAnalysis)
|
coretrace_python/cache.py
CHANGED
|
@@ -35,7 +35,6 @@ from coretrace_python.interprocedural import (
|
|
|
35
35
|
Mutation,
|
|
36
36
|
NonlocalWrite,
|
|
37
37
|
PriorCall,
|
|
38
|
-
SummaryIndex,
|
|
39
38
|
SymbolRead,
|
|
40
39
|
Target,
|
|
41
40
|
UnknownTarget,
|
|
@@ -43,7 +42,7 @@ from coretrace_python.interprocedural import (
|
|
|
43
42
|
from coretrace_python.semantic.symbols import SymbolId
|
|
44
43
|
from coretrace_python.source import FileLocation, Location, SourceId, SourceSpan
|
|
45
44
|
|
|
46
|
-
CACHE_FORMAT =
|
|
45
|
+
CACHE_FORMAT = 16
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
@dataclass(frozen=True)
|
|
@@ -155,16 +154,6 @@ def decode(data: Mapping[str, Any]) -> CachedModule:
|
|
|
155
154
|
)
|
|
156
155
|
|
|
157
156
|
|
|
158
|
-
def encode_index(index: SummaryIndex) -> dict[str, Any]:
|
|
159
|
-
"""A summary index as plain data, to hand a worker process what it imports."""
|
|
160
|
-
|
|
161
|
-
return {str(symbol): _encode_summary(index.summaries[symbol]) for symbol in index.symbols}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def decode_index(data: Mapping[str, Any]) -> SummaryIndex:
|
|
165
|
-
return SummaryIndex({SymbolId(_string(k)): _decode_summary(v) for k, v in data.items()})
|
|
166
|
-
|
|
167
|
-
|
|
168
157
|
def _string(value: Any) -> str:
|
|
169
158
|
if not isinstance(value, str):
|
|
170
159
|
raise TypeError(f"expected a string, got {value!r}")
|
|
@@ -264,11 +253,13 @@ def _encode_call(call: ExternalCall) -> dict[str, Any]:
|
|
|
264
253
|
"given": _encode_arguments(call.arguments),
|
|
265
254
|
"argument_cleared": [_encode_cleared(c) for c in call.argument_cleared],
|
|
266
255
|
"keyword_cleared": [_encode_cleared(c) for c in call.keyword_cleared],
|
|
256
|
+
"invoked_at": None if call.invoked_at is None else _encode_span(call.invoked_at),
|
|
267
257
|
}
|
|
268
258
|
|
|
269
259
|
|
|
270
260
|
def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
|
|
271
261
|
site = data["call_site"]
|
|
262
|
+
invoked_at = data["invoked_at"]
|
|
272
263
|
return ExternalCall(
|
|
273
264
|
SymbolId(_string(data["symbol"])),
|
|
274
265
|
tuple(_indices(deps) for deps in data["arguments"]),
|
|
@@ -278,6 +269,7 @@ def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
|
|
|
278
269
|
_decode_arguments(data["given"]),
|
|
279
270
|
tuple(_decode_cleared(c) for c in data["argument_cleared"]),
|
|
280
271
|
tuple(_decode_cleared(c) for c in data["keyword_cleared"]),
|
|
272
|
+
None if invoked_at is None else _decode_span(invoked_at),
|
|
281
273
|
)
|
|
282
274
|
|
|
283
275
|
|
|
@@ -308,7 +300,12 @@ def _encode_summary(summary: FunctionSummary) -> dict[str, Any]:
|
|
|
308
300
|
],
|
|
309
301
|
"side_effects": sorted(summary.side_effects),
|
|
310
302
|
"nonlocal_writes": [
|
|
311
|
-
{
|
|
303
|
+
{
|
|
304
|
+
"name": w.name,
|
|
305
|
+
"dependencies": sorted(w.dependencies),
|
|
306
|
+
"externals": sorted(str(s) for s in w.externals),
|
|
307
|
+
"kept": w.kept,
|
|
308
|
+
}
|
|
312
309
|
for w in summary.nonlocal_writes
|
|
313
310
|
],
|
|
314
311
|
"static": summary.static,
|
|
@@ -336,7 +333,10 @@ def _decode_summary(data: Mapping[str, Any]) -> FunctionSummary:
|
|
|
336
333
|
frozenset(_string(name) for name in data["side_effects"]),
|
|
337
334
|
tuple(
|
|
338
335
|
NonlocalWrite(
|
|
339
|
-
_string(w["name"]),
|
|
336
|
+
_string(w["name"]),
|
|
337
|
+
_indices(w["dependencies"]),
|
|
338
|
+
frozenset(SymbolId(_string(s)) for s in w["externals"]),
|
|
339
|
+
bool(w["kept"]),
|
|
340
340
|
)
|
|
341
341
|
for w in data["nonlocal_writes"]
|
|
342
342
|
),
|
coretrace_python/cli.py
CHANGED
|
@@ -109,11 +109,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
109
109
|
"them for modules unchanged since the previous run",
|
|
110
110
|
)
|
|
111
111
|
parser.add_argument(
|
|
112
|
+
"-j",
|
|
112
113
|
"--jobs",
|
|
113
114
|
type=int,
|
|
114
115
|
default=None,
|
|
115
116
|
metavar="N",
|
|
116
|
-
help="with --check on a directory, analyse independent modules in N processes"
|
|
117
|
+
help="with --check on a directory, analyse independent modules in N processes, "
|
|
118
|
+
"0 for one per available core; default: 1",
|
|
117
119
|
)
|
|
118
120
|
parser.add_argument(
|
|
119
121
|
"--sbom",
|
|
@@ -214,8 +216,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
214
216
|
if args.jobs is not None and not (args.check and args.path.is_dir()):
|
|
215
217
|
print("error: --jobs only applies to --check on a directory", file=sys.stderr)
|
|
216
218
|
return EXIT_ERROR
|
|
217
|
-
if args.jobs is not None and args.jobs <
|
|
218
|
-
print("error: --jobs must be
|
|
219
|
+
if args.jobs is not None and args.jobs < 0:
|
|
220
|
+
print("error: --jobs must be 0 (one per available core) or more", file=sys.stderr)
|
|
219
221
|
return EXIT_ERROR
|
|
220
222
|
if args.sbom is not None and not (args.check and args.path.is_dir()):
|
|
221
223
|
print("error: --sbom only applies to --check on a directory", file=sys.stderr)
|
|
@@ -270,7 +272,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
270
272
|
args.path,
|
|
271
273
|
plugin_roots,
|
|
272
274
|
cache=cache,
|
|
273
|
-
jobs=args.jobs
|
|
275
|
+
jobs=_processes(args.jobs),
|
|
274
276
|
advisory_files=args.advisories,
|
|
275
277
|
policy_file=args.policy,
|
|
276
278
|
)
|
|
@@ -301,7 +303,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
301
303
|
encoding="utf-8",
|
|
302
304
|
)
|
|
303
305
|
else:
|
|
304
|
-
file_analysis = engine.analyze_file(SourceManager().load_file(args.path), plugin_roots)
|
|
306
|
+
file_analysis = engine.analyze_file(SourceManager().load_file(args.path), plugin_roots, root)
|
|
305
307
|
findings, coverage = file_analysis.findings, file_analysis.coverage
|
|
306
308
|
suppressed = file_analysis.suppressed
|
|
307
309
|
components = ()
|
|
@@ -331,5 +333,31 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
331
333
|
return EXIT_CLEAN
|
|
332
334
|
|
|
333
335
|
|
|
336
|
+
def _processes(jobs: int | None) -> int:
|
|
337
|
+
"""The number of processes ``--jobs`` asks for: 0 is one per core the process may run
|
|
338
|
+
on, as ``runtime.jobs`` of ``ctrace``; none is one."""
|
|
339
|
+
|
|
340
|
+
if jobs is None:
|
|
341
|
+
return 1
|
|
342
|
+
if jobs == 0:
|
|
343
|
+
return _available_cores()
|
|
344
|
+
return jobs
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _available_cores() -> int:
|
|
348
|
+
"""The cores the process may run on, where Python can tell its affinity
|
|
349
|
+
(``os.process_cpu_count`` from 3.13, ``os.sched_getaffinity`` on Linux before), else
|
|
350
|
+
the cores of the machine."""
|
|
351
|
+
|
|
352
|
+
process_cpu_count = getattr(os, "process_cpu_count", None)
|
|
353
|
+
if process_cpu_count is not None:
|
|
354
|
+
cores: int | None = process_cpu_count()
|
|
355
|
+
elif hasattr(os, "sched_getaffinity"):
|
|
356
|
+
cores = len(os.sched_getaffinity(0))
|
|
357
|
+
else:
|
|
358
|
+
cores = os.cpu_count()
|
|
359
|
+
return cores or 1
|
|
360
|
+
|
|
361
|
+
|
|
334
362
|
if __name__ == "__main__":
|
|
335
363
|
raise SystemExit(main())
|