coretrace-python-analyzer 0.21.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/dependency/vulnerable_dependency/vulnerable_dependency.py +23 -6
- 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/security/command_injection/command_injection.py +1028 -3
- coretrace_python/bundled/security/command_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 +35 -17
- coretrace_python/cli.py +84 -14
- coretrace_python/dependency/graph.py +51 -30
- coretrace_python/dependency/vex.py +5 -2
- coretrace_python/engine.py +203 -60
- coretrace_python/findings/baseline.py +268 -34
- coretrace_python/findings/context.py +57 -0
- coretrace_python/findings/model.py +3 -2
- coretrace_python/findings/refutation.py +210 -27
- coretrace_python/findings/suppressions.py +11 -3
- coretrace_python/frontend/ast_adapter.py +27 -1
- coretrace_python/frontend/parser.py +10 -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 +6 -2
- coretrace_python/ir/model.py +13 -2
- coretrace_python/ir/printer.py +4 -1
- coretrace_python/plugins/__init__.py +8 -1
- coretrace_python/plugins/api.py +16 -3
- coretrace_python/plugins/detectors.py +51 -7
- coretrace_python/plugins/secrets.py +245 -44
- coretrace_python/reporters/json_format.py +14 -7
- coretrace_python/reporters/report.py +7 -2
- coretrace_python/reporters/sarif.py +31 -12
- coretrace_python/reporters/text.py +5 -4
- coretrace_python/semantic/symbols.py +2 -1
- coretrace_python/source/__init__.py +6 -2
- coretrace_python/source/manager.py +16 -0
- coretrace_python/source/model.py +20 -0
- coretrace_python/source/positions.py +420 -0
- coretrace_python/taint/commands.py +45 -0
- coretrace_python/taint/engine.py +114 -27
- coretrace_python/taint/models.py +27 -3
- {coretrace_python_analyzer-0.21.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/METADATA +10 -1
- {coretrace_python_analyzer-0.21.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/RECORD +52 -48
- {coretrace_python_analyzer-0.21.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/WHEEL +0 -0
- {coretrace_python_analyzer-0.21.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/entry_points.txt +0 -0
- {coretrace_python_analyzer-0.21.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/licenses/LICENSE +0 -0
- {coretrace_python_analyzer-0.21.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
|
+
)
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Sequence
|
|
6
|
+
from pathlib import PurePath
|
|
6
7
|
from typing import ClassVar
|
|
7
8
|
|
|
8
9
|
from coretrace_python.analysis import AnyAnalysis
|
|
9
|
-
from coretrace_python.dependency import DependencyAnalysis
|
|
10
|
+
from coretrace_python.dependency import Advisory, DependencyAnalysis, Requirement
|
|
10
11
|
from coretrace_python.dependency.correlation import (
|
|
11
12
|
affected_entries,
|
|
12
13
|
affected_symbols,
|
|
@@ -44,11 +45,7 @@ class VulnerableDependencyPlugin(ProjectPlugin):
|
|
|
44
45
|
findings.append(
|
|
45
46
|
Finding(
|
|
46
47
|
rule_id="vulnerable-dependency",
|
|
47
|
-
message=(
|
|
48
|
-
f"{advisory.id}: {advisory.package} {advisory.vulnerable} is "
|
|
49
|
-
f"{'required' if pinned else 'allowed'} by {requirement.name}"
|
|
50
|
-
f"{requirement.specifier or ''}: {advisory.summary}"
|
|
51
|
-
),
|
|
48
|
+
message=_message(ctx, advisory, requirement),
|
|
52
49
|
severity=advisory.severity,
|
|
53
50
|
confidence=Confidence.HIGH if pinned else Confidence.MEDIUM,
|
|
54
51
|
span=requirement.span,
|
|
@@ -58,6 +55,26 @@ class VulnerableDependencyPlugin(ProjectPlugin):
|
|
|
58
55
|
return findings
|
|
59
56
|
|
|
60
57
|
|
|
58
|
+
def _message(ctx: ProjectContext, advisory: Advisory, requirement: Requirement) -> str:
|
|
59
|
+
"""The evidence: for a locked package, the version the lock file pins and the
|
|
60
|
+
packages that require it; for a declared requirement, the specifier that allows or
|
|
61
|
+
requires a vulnerable version."""
|
|
62
|
+
|
|
63
|
+
if requirement.locked is None:
|
|
64
|
+
verb = "required" if requirement.pinned is not None else "allowed"
|
|
65
|
+
return (
|
|
66
|
+
f"{advisory.id}: {advisory.package} {advisory.vulnerable} is {verb} by "
|
|
67
|
+
f"{requirement.name}{requirement.specifier or ''}: {advisory.summary}"
|
|
68
|
+
)
|
|
69
|
+
lock = PurePath(str(requirement.span.source_id)).name
|
|
70
|
+
dependents = sorted(ctx.dependencies.required_by(requirement.name) or ())
|
|
71
|
+
required = f" (required by {', '.join(dependents)})" if dependents else ""
|
|
72
|
+
return (
|
|
73
|
+
f"{advisory.id}: {advisory.package} {requirement.locked}, pinned in {lock}, "
|
|
74
|
+
f"is in {advisory.vulnerable}{required}: {advisory.summary}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
61
78
|
def _ruled_out(ctx: ProjectContext) -> dict[str, list[str]]:
|
|
62
79
|
"""By advisory, the calls to its entry points whose arguments contradict one of its
|
|
63
80
|
conditions: the evidence that the requirement, imported, is not reached there."""
|
|
@@ -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]
|