Skip to content

Commit 153d8f6

Browse files
fix(security): prevent command injection via shell=True (CWE-78)
Replace shell=True with list-based subprocess calls for all git.py functions that interpolate user-controlled values (tag names, messages, file paths, git references). This prevents shell injection attacks where malicious values in pyproject.toml could execute arbitrary commands during CI/CD runs of 'cz bump'. Changes: - cmd.run() now accepts str | Sequence[str]; lists use shell=False - git.tag() uses list args (fixes primary attack vector) - git.add() uses list args - git.commit() uses list args + env= for GIT_COMMITTER_DATE - git.tag_exist/is_signed_tag/get_tag_message use list args - git.get_filenames_in_commit() uses list args - git.get_tags() uses list args - git._get_log_as_str_list() uses list args Closes #1918 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 509ef91 commit 153d8f6

17 files changed

Lines changed: 290 additions & 147 deletions

commitizen/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,7 @@ def main() -> None:
704704
logger.warning(
705705
"\nWARN: Incomplete commit command: received -- separator without any following git arguments\n"
706706
)
707-
extra_args = " ".join(unknown_args[1:])
707+
extra_args = unknown_args[1:]
708708
arguments["extra_cli_args"] = extra_args
709709

710710
conf = config.read_cfg(args.config)

commitizen/cmd.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import os
44
import subprocess
5-
from typing import TYPE_CHECKING, NamedTuple
5+
import warnings
6+
from typing import TYPE_CHECKING, NamedTuple, overload
67

78
from charset_normalizer import from_bytes
89

910
from commitizen.exceptions import CharacterSetDecodeError
1011

1112
if TYPE_CHECKING:
12-
from collections.abc import Mapping
13+
from collections.abc import Mapping, Sequence
1314

1415

1516
class Command(NamedTuple):
@@ -35,12 +36,18 @@ def _try_decode(bytes_: bytes) -> str:
3536
raise CharacterSetDecodeError() from e
3637

3738

38-
def run(cmd: str, env: Mapping[str, str] | None = None) -> Command:
39+
def _popen(
40+
cmd: str | Sequence[str],
41+
*,
42+
shell: bool,
43+
env: Mapping[str, str] | None = None,
44+
) -> Command:
3945
if env is not None:
4046
env = {**os.environ, **env}
47+
4148
process = subprocess.Popen(
4249
cmd,
43-
shell=True,
50+
shell=shell,
4451
stdout=subprocess.PIPE,
4552
stderr=subprocess.PIPE,
4653
stdin=subprocess.PIPE,
@@ -55,3 +62,45 @@ def run(cmd: str, env: Mapping[str, str] | None = None) -> Command:
5562
stderr,
5663
return_code,
5764
)
65+
66+
67+
@overload
68+
def run(cmd: Sequence[str], env: Mapping[str, str] | None = None) -> Command: ...
69+
70+
71+
@overload
72+
def run(cmd: str, env: Mapping[str, str] | None = None) -> Command: ...
73+
74+
75+
def run(cmd: str | Sequence[str], env: Mapping[str, str] | None = None) -> Command:
76+
"""Run a command safely without shell interpretation (shell=False).
77+
78+
Arguments are passed directly to the OS, preventing shell-injection
79+
vulnerabilities (CWE-78).
80+
81+
Passing a string is deprecated and will be removed in a future version.
82+
Use a list of arguments instead, or use run_shell() for shell features.
83+
"""
84+
if isinstance(cmd, str):
85+
warnings.warn(
86+
"Passing a string to cmd.run() is deprecated and will use shell=True. "
87+
"Pass a list of arguments for shell=False (safe), "
88+
"or use cmd.run_shell() explicitly for shell features.",
89+
DeprecationWarning,
90+
stacklevel=2,
91+
)
92+
return _popen(cmd, shell=True, env=env)
93+
return _popen(cmd, shell=False, env=env)
94+
95+
96+
def run_shell(cmd: str, env: Mapping[str, str] | None = None) -> Command:
97+
"""Run a command string via the system shell (shell=True).
98+
99+
Only use this for cases that intentionally require shell features
100+
(e.g., user-defined hooks with pipes/redirects). Never pass
101+
untrusted/user-controlled values into *cmd*.
102+
103+
Related: CWE-78 (OS Command Injection),
104+
https://github.com/commitizen-tools/commitizen/issues/1918
105+
"""
106+
return _popen(cmd, shell=True, env=env)

commitizen/commands/bump.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,8 +439,8 @@ def __call__(self) -> None:
439439
else:
440440
out.success("Done!")
441441

442-
def _get_commit_args(self) -> str:
442+
def _get_commit_args(self) -> list[str]:
443443
commit_args = ["-a"]
444444
if self.no_verify:
445445
commit_args.append("--no-verify")
446-
return " ".join(commit_args)
446+
return commit_args

commitizen/commands/changelog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ def __call__(self) -> None:
257257
changelog_meta.latest_version_position = None
258258
changelog_meta.unreleased_end = latest_full_release_info.index + 1
259259

260-
commits = git.get_commits(start=start_rev, end=end_rev, args="--topo-order")
260+
commits = git.get_commits(start=start_rev, end=end_rev, args=["--topo-order"])
261261
if (
262262
not self.allow_no_commit
263263
and not commits

commitizen/commands/commit.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class CommitArgs(TypedDict, total=False):
3434
all: bool
3535
dry_run: bool
3636
edit: bool
37-
extra_cli_args: str
37+
extra_cli_args: list[str]
3838
message_length_limit: int
3939
no_retry: bool
4040
signoff: bool
@@ -132,7 +132,7 @@ def _get_message(self) -> str:
132132
return self._get_message_by_prompt_commit_questions()
133133

134134
def __call__(self) -> None:
135-
extra_args = self.arguments.get("extra_cli_args", "")
135+
extra_args: list[str] = self.arguments.get("extra_cli_args", [])
136136
dry_run = bool(self.arguments.get("dry_run"))
137137
write_message_to_file = self.arguments.get("write_message_to_file")
138138
signoff = bool(self.arguments.get("signoff"))
@@ -167,7 +167,7 @@ def __call__(self) -> None:
167167
raise DryRunExit()
168168

169169
if self.config.settings["always_signoff"] or signoff:
170-
extra_args = f"{extra_args} -s".strip()
170+
extra_args = [*extra_args, "-s"]
171171

172172
c = git.commit(commit_message, args=extra_args)
173173
if c.return_code != 0:

commitizen/commands/init.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,12 @@ def __call__(self) -> None:
134134
"pre-commit is not installed in current environment."
135135
)
136136

137-
cmd_str = "pre-commit install " + " ".join(
138-
f"--hook-type {ty}" for ty in hook_types
139-
)
140-
c = cmd.run(cmd_str)
137+
cmd_args = ["pre-commit", "install"]
138+
for ty in hook_types:
139+
cmd_args.extend(["--hook-type", ty])
140+
c = cmd.run(cmd_args)
141141
if c.return_code != 0:
142+
cmd_str = " ".join(cmd_args)
142143
raise InitFailedError(
143144
"Failed to install pre-commit hook.\n"
144145
f"Error running {cmd_str}."

commitizen/git.py

Lines changed: 53 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
from functools import lru_cache
66
from pathlib import Path
77
from tempfile import NamedTemporaryFile
8+
from typing import TYPE_CHECKING
89

910
from commitizen import cmd, out
1011
from commitizen.exceptions import GitCommandError
1112

13+
if TYPE_CHECKING:
14+
from collections.abc import Sequence
15+
1216

1317
class EOLType(Enum):
1418
"""The EOL type from `git config core.eol`."""
@@ -19,7 +23,7 @@ class EOLType(Enum):
1923

2024
@classmethod
2125
def for_open(cls) -> str:
22-
c = cmd.run("git config core.eol")
26+
c = cmd.run(["git", "config", "core.eol"])
2327
eol = c.out.strip().upper()
2428
return cls._char_for_open()[cls._safe_cast(eol)]
2529

@@ -164,49 +168,47 @@ def tag(
164168
tag: str, annotated: bool = False, signed: bool = False, msg: str | None = None
165169
) -> cmd.Command:
166170
if not annotated and not signed:
167-
return cmd.run(f"git tag {tag}")
171+
return cmd.run(["git", "tag", tag])
168172

169173
# according to https://git-scm.com/book/en/v2/Git-Basics-Tagging,
170174
# we're not able to create lightweight tag with message.
171175
# by adding message, we make it a annotated tags
172176
option = "-s" if signed else "-a" # The else case is for annotated tags
173-
return cmd.run(f'git tag {option} {tag} -m "{msg or tag}"')
177+
return cmd.run(["git", "tag", option, tag, "-m", msg or tag])
174178

175179

176180
def add(*args: str) -> cmd.Command:
177-
return cmd.run(f"git add {' '.join(args)}")
181+
return cmd.run(["git", "add", *args])
178182

179183

180184
def commit(
181185
message: str,
182-
args: str = "",
186+
args: Sequence[str] = (),
183187
committer_date: str | None = None,
184188
) -> cmd.Command:
185189
f = NamedTemporaryFile("wb", delete=False)
186190
f.write(message.encode("utf-8"))
187191
f.close()
188192

189-
command = _create_commit_cmd_string(args, committer_date, f.name)
190-
c = cmd.run(command)
191-
os.unlink(f.name)
192-
return c
193+
cmd_args = ["git", "commit"]
194+
if args:
195+
cmd_args.extend(args)
196+
cmd_args.extend(["-F", f.name])
193197

198+
env: dict[str, str] | None = None
199+
if committer_date:
200+
env = {"GIT_COMMITTER_DATE": committer_date}
194201

195-
def _create_commit_cmd_string(args: str, committer_date: str | None, name: str) -> str:
196-
command = f'git commit {args} -F "{name}"'
197-
if not committer_date:
198-
return command
199-
if os.name != "nt":
200-
return f"GIT_COMMITTER_DATE={committer_date} {command}"
201-
# Using `cmd /v /c "{command}"` sets environment variables only for that command
202-
return f'cmd /v /c "set GIT_COMMITTER_DATE={committer_date}&& {command}"'
202+
c = cmd.run(cmd_args, env=env)
203+
os.unlink(f.name)
204+
return c
203205

204206

205207
def get_commits(
206208
start: str | None = None,
207209
end: str | None = None,
208210
*,
209-
args: str = "",
211+
args: Sequence[str] = (),
210212
) -> list[GitCommit]:
211213
"""Get the commits between start and end."""
212214
if end is None:
@@ -226,7 +228,10 @@ def get_filenames_in_commit(git_reference: str = "") -> list[str]:
226228
227229
:returns: file names committed in the last commit by default or inside the passed git reference
228230
"""
229-
c = cmd.run(f"git show --name-only --pretty=format: {git_reference}")
231+
cmd_args = ["git", "show", "--name-only", "--pretty=format:"]
232+
if git_reference:
233+
cmd_args.append(git_reference)
234+
c = cmd.run(cmd_args)
230235
if c.return_code == 0:
231236
return c.out.strip().split("\n")
232237
raise GitCommandError(c.err)
@@ -237,15 +242,17 @@ def get_tags(
237242
) -> list[GitTag]:
238243
inner_delimiter = "---inner_delimiter---"
239244
formatter = (
240-
f'"%(refname:strip=2){inner_delimiter}'
245+
f"%(refname:strip=2){inner_delimiter}"
241246
f"%(objectname){inner_delimiter}"
242247
f"%(creatordate:format:{dateformat}){inner_delimiter}"
243-
f'%(object)"'
248+
f"%(object)"
244249
)
245-
extra = "--merged" if reachable_only else ""
250+
cmd_args = ["git", "tag", f"--format={formatter}", "--sort=-creatordate"]
251+
if reachable_only:
252+
cmd_args.append("--merged")
246253
# Force the default language for parsing
247254
env = {"LC_ALL": "C", "LANG": "C", "LANGUAGE": "C"}
248-
c = cmd.run(f"git tag --format={formatter} --sort=-creatordate {extra}", env=env)
255+
c = cmd.run(cmd_args, env=env)
249256
if c.return_code != 0:
250257
if reachable_only and c.err == "fatal: malformed object name HEAD\n":
251258
# this can happen if there are no commits in the repo yet
@@ -262,55 +269,55 @@ def get_tags(
262269

263270

264271
def tag_exist(tag: str) -> bool:
265-
c = cmd.run(f"git tag --list {tag}")
272+
c = cmd.run(["git", "tag", "--list", tag])
266273
return tag in c.out
267274

268275

269276
def is_signed_tag(tag: str) -> bool:
270-
return cmd.run(f"git tag -v {tag}").return_code == 0
277+
return cmd.run(["git", "tag", "-v", tag]).return_code == 0
271278

272279

273280
def get_latest_tag_name() -> str | None:
274-
c = cmd.run("git describe --abbrev=0 --tags")
281+
c = cmd.run(["git", "describe", "--abbrev=0", "--tags"])
275282
if c.err:
276283
return None
277284
return c.out.strip()
278285

279286

280287
def get_tag_message(tag: str) -> str | None:
281-
c = cmd.run(f"git tag -l --format='%(contents:subject)' {tag}")
288+
c = cmd.run(["git", "tag", "-l", "--format=%(contents:subject)", tag])
282289
if c.err:
283290
return None
284291
return c.out.strip()
285292

286293

287294
def get_tag_names() -> list[str]:
288-
c = cmd.run("git tag --list")
295+
c = cmd.run(["git", "tag", "--list"])
289296
if c.err:
290297
return []
291298
return [tag for raw in c.out.split("\n") if (tag := raw.strip())]
292299

293300

294301
def find_git_project_root() -> Path | None:
295-
c = cmd.run("git rev-parse --show-toplevel")
302+
c = cmd.run(["git", "rev-parse", "--show-toplevel"])
296303
if c.err:
297304
return None
298305
return Path(c.out.strip())
299306

300307

301308
def is_staging_clean() -> bool:
302309
"""Check if staging is clean."""
303-
c = cmd.run("git diff --no-ext-diff --cached --name-only")
310+
c = cmd.run(["git", "diff", "--no-ext-diff", "--cached", "--name-only"])
304311
return not bool(c.out)
305312

306313

307314
def is_git_project() -> bool:
308-
c = cmd.run("git rev-parse --is-inside-work-tree")
315+
c = cmd.run(["git", "rev-parse", "--is-inside-work-tree"])
309316
return c.out.strip() == "true"
310317

311318

312319
def get_core_editor() -> str | None:
313-
c = cmd.run("git var GIT_EDITOR")
320+
c = cmd.run(["git", "var", "GIT_EDITOR"])
314321
if c.out:
315322
return c.out.strip()
316323
return None
@@ -321,21 +328,31 @@ def smart_open(*args, **kwargs): # type: ignore[no-untyped-def,unused-ignore] #
321328
return open(*args, newline=EOLType.for_open(), **kwargs)
322329

323330

324-
def _get_log_as_str_list(start: str | None, end: str, args: str) -> list[str]:
331+
def _get_log_as_str_list(start: str | None, end: str, args: Sequence[str]) -> list[str]:
325332
"""Get string representation of each log entry"""
326333
delimiter = "----------commit-delimiter----------"
327334
log_format: str = "%H%n%P%n%s%n%an%n%ae%n%b"
328335
command_range = f"{start}..{end}" if start else end
329-
command = f"git -c log.showSignature=False log --pretty={log_format}{delimiter} {args} {command_range}"
336+
cmd_args = [
337+
"git",
338+
"-c",
339+
"log.showSignature=False",
340+
"log",
341+
f"--pretty={log_format}{delimiter}",
342+
]
343+
if args:
344+
cmd_args.extend(args)
345+
if command_range:
346+
cmd_args.append(command_range)
330347

331-
c = cmd.run(command)
348+
c = cmd.run(cmd_args)
332349
if c.return_code != 0:
333350
raise GitCommandError(c.err)
334351
return c.out.split(f"{delimiter}\n")
335352

336353

337354
def get_default_branch() -> str:
338-
c = cmd.run("git symbolic-ref refs/remotes/origin/HEAD")
355+
c = cmd.run(["git", "symbolic-ref", "refs/remotes/origin/HEAD"])
339356
if c.return_code != 0:
340357
raise GitCommandError(c.err)
341358
return c.out.strip()

commitizen/hooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def run(hooks: str | list[str], _env_prefix: str = "CZ_", **env: object) -> None
1717
for hook in hooks:
1818
out.info(f"Running hook '{hook}'")
1919

20-
c = cmd.run(hook, env=_format_env(_env_prefix, env))
20+
c = cmd.run_shell(hook, env=_format_env(_env_prefix, env))
2121

2222
if c.out:
2323
out.write(c.out)

0 commit comments

Comments
 (0)