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.
Files changed (35) hide show
  1. coretrace_python/__init__.py +1 -1
  2. coretrace_python/abstract/module_constants.py +121 -0
  3. coretrace_python/abstract/strings.py +12 -42
  4. coretrace_python/bundled/models/python_stdlib/python_stdlib.py +27 -0
  5. coretrace_python/bundled/secrets/config_secrets/config_secrets.py +2 -1
  6. coretrace_python/bundled/security/code_injection/code_injection.py +85 -4
  7. coretrace_python/bundled/security/code_injection/plugin.toml +1 -1
  8. coretrace_python/bundled/syntax/unsafe_xml/plugin.toml +1 -1
  9. coretrace_python/bundled/syntax/unsafe_xml/unsafe_xml.py +92 -12
  10. coretrace_python/cache.py +14 -14
  11. coretrace_python/cli.py +33 -5
  12. coretrace_python/engine.py +197 -60
  13. coretrace_python/findings/context.py +57 -0
  14. coretrace_python/findings/refutation.py +210 -27
  15. coretrace_python/frontend/parser.py +7 -1
  16. coretrace_python/interprocedural/callgraph.py +14 -1
  17. coretrace_python/interprocedural/summaries.py +91 -19
  18. coretrace_python/ir/defuse.py +26 -1
  19. coretrace_python/ir/lowering.py +2 -1
  20. coretrace_python/ir/model.py +5 -2
  21. coretrace_python/ir/printer.py +4 -1
  22. coretrace_python/plugins/__init__.py +7 -1
  23. coretrace_python/plugins/api.py +16 -3
  24. coretrace_python/plugins/detectors.py +21 -3
  25. coretrace_python/plugins/secrets.py +17 -11
  26. coretrace_python/reporters/sarif.py +15 -3
  27. coretrace_python/semantic/symbols.py +2 -1
  28. coretrace_python/taint/engine.py +114 -27
  29. coretrace_python/taint/models.py +27 -3
  30. {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/METADATA +1 -1
  31. {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/RECORD +35 -33
  32. {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/WHEEL +0 -0
  33. {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/entry_points.txt +0 -0
  34. {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/licenses/LICENSE +0 -0
  35. {coretrace_python_analyzer-0.22.0.dist-info → coretrace_python_analyzer-0.23.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,4 +1,4 @@
1
1
  """CoreTrace's Python static analysis frontend."""
2
2
 
3
- __version__ = "0.22.0"
3
+ __version__ = "0.23.0"
4
4
 
@@ -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.cfg import CFGError
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, to a string literal, and never rebound: by no
63
- other statement of the module body, no function assigning them as ``global``, no
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
- try:
72
- functions = [ctx.get(PyIRAnalysis, function) for function in analyzable_functions(ctx.module)]
73
- except (LoweringError, CFGError):
74
- return MappingProxyType({})
75
- body = next((function for function in functions if function.name == MODULE_BODY), None)
76
- if body is None:
77
- return MappingProxyType({})
78
- defs = {i.result: i for block in body.blocks for i in block.instructions if i.result is not None}
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. ``dangerous-eval`` reports every such call; this rule reports
3
- the ones attacker input reaches."""
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.plugins import TaintDetector
11
- from coretrace_python.taint import TaintKind
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,7 +1,7 @@
1
1
  name = "unsafe-xml"
2
2
  version = "1.0.0"
3
3
  plugin_api = ">=1,<2"
4
- requires = ["interprocedural.callgraph", "ir.ssa"]
4
+ requires = ["interprocedural.callgraph", "ir.ssa", "abstract.module_strings"]
5
5
  provides = ["vulnerability.unsafe-xml"]
6
6
 
7
7
  [entrypoint]
@@ -1,35 +1,115 @@
1
- """XML parsers of the standard library and lxml expand entities: a crafted document reads
2
- local files or exhausts memory. ``defusedxml`` offers the same functions safely."""
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
- _PARSERS = (
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]] = frozenset(SymbolId(f"python.{p}") for p in _PARSERS)
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
- "call to {symbol} parses XML with entity expansion enabled; use defusedxml"
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 = 14
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
- {"name": w.name, "dependencies": sorted(w.dependencies), "externals": sorted(str(s) for s in w.externals)}
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"]), _indices(w["dependencies"]), frozenset(SymbolId(_string(s)) for s in w["externals"])
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 < 1:
218
- print("error: --jobs must be at least 1", file=sys.stderr)
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 or 1,
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())