Skip to content

Commit 11d6890

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 <[email protected]>
1 parent 35ffe03 commit 11d6890

3 files changed

Lines changed: 86 additions & 57 deletions

File tree

commitizen/cmd.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from commitizen.exceptions import CharacterSetDecodeError
1010

1111
if TYPE_CHECKING:
12-
from collections.abc import Mapping
12+
from collections.abc import Mapping, Sequence
1313

1414

1515
class Command(NamedTuple):
@@ -35,12 +35,21 @@ def _try_decode(bytes_: bytes) -> str:
3535
raise CharacterSetDecodeError() from e
3636

3737

38-
def run(cmd: str, env: Mapping[str, str] | None = None) -> Command:
38+
def run(cmd: str | Sequence[str], env: Mapping[str, str] | None = None) -> Command:
39+
"""Run a command and return its output.
40+
41+
When *cmd* is a list/sequence of strings the command is executed directly
42+
(shell=False) which avoids shell-injection vulnerabilities (CWE-78).
43+
When *cmd* is a plain string it is executed via the shell for backward
44+
compatibility with callers that rely on shell features.
45+
"""
3946
if env is not None:
4047
env = {**os.environ, **env}
48+
49+
use_shell = isinstance(cmd, str)
4150
process = subprocess.Popen(
4251
cmd,
43-
shell=True,
52+
shell=use_shell,
4453
stdout=subprocess.PIPE,
4554
stderr=subprocess.PIPE,
4655
stdin=subprocess.PIPE,

commitizen/git.py

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,17 @@ def tag(
164164
tag: str, annotated: bool = False, signed: bool = False, msg: str | None = None
165165
) -> cmd.Command:
166166
if not annotated and not signed:
167-
return cmd.run(f"git tag {tag}")
167+
return cmd.run(["git", "tag", tag])
168168

169169
# according to https://git-scm.com/book/en/v2/Git-Basics-Tagging,
170170
# we're not able to create lightweight tag with message.
171171
# by adding message, we make it a annotated tags
172172
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}"')
173+
return cmd.run(["git", "tag", option, tag, "-m", msg or tag])
174174

175175

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

179179

180180
def commit(
@@ -186,20 +186,18 @@ def commit(
186186
f.write(message.encode("utf-8"))
187187
f.close()
188188

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

194+
env: dict[str, str] | None = None
195+
if committer_date:
196+
env = {"GIT_COMMITTER_DATE": committer_date}
194197

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}"'
198+
c = cmd.run(cmd_args, env=env)
199+
os.unlink(f.name)
200+
return c
203201

204202

205203
def get_commits(
@@ -226,7 +224,10 @@ def get_filenames_in_commit(git_reference: str = "") -> list[str]:
226224
227225
:returns: file names committed in the last commit by default or inside the passed git reference
228226
"""
229-
c = cmd.run(f"git show --name-only --pretty=format: {git_reference}")
227+
cmd_args = ["git", "show", "--name-only", "--pretty=format:"]
228+
if git_reference:
229+
cmd_args.append(git_reference)
230+
c = cmd.run(cmd_args)
230231
if c.return_code == 0:
231232
return c.out.strip().split("\n")
232233
raise GitCommandError(c.err)
@@ -237,15 +238,17 @@ def get_tags(
237238
) -> list[GitTag]:
238239
inner_delimiter = "---inner_delimiter---"
239240
formatter = (
240-
f'"%(refname:strip=2){inner_delimiter}'
241+
f"%(refname:strip=2){inner_delimiter}"
241242
f"%(objectname){inner_delimiter}"
242243
f"%(creatordate:format:{dateformat}){inner_delimiter}"
243-
f'%(object)"'
244+
f"%(object)"
244245
)
245-
extra = "--merged" if reachable_only else ""
246+
cmd_args = ["git", "tag", f"--format={formatter}", "--sort=-creatordate"]
247+
if reachable_only:
248+
cmd_args.append("--merged")
246249
# Force the default language for parsing
247250
env = {"LC_ALL": "C", "LANG": "C", "LANGUAGE": "C"}
248-
c = cmd.run(f"git tag --format={formatter} --sort=-creatordate {extra}", env=env)
251+
c = cmd.run(cmd_args, env=env)
249252
if c.return_code != 0:
250253
if reachable_only and c.err == "fatal: malformed object name HEAD\n":
251254
# this can happen if there are no commits in the repo yet
@@ -262,12 +265,12 @@ def get_tags(
262265

263266

264267
def tag_exist(tag: str) -> bool:
265-
c = cmd.run(f"git tag --list {tag}")
268+
c = cmd.run(["git", "tag", "--list", tag])
266269
return tag in c.out
267270

268271

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

272275

273276
def get_latest_tag_name() -> str | None:
@@ -278,7 +281,7 @@ def get_latest_tag_name() -> str | None:
278281

279282

280283
def get_tag_message(tag: str) -> str | None:
281-
c = cmd.run(f"git tag -l --format='%(contents:subject)' {tag}")
284+
c = cmd.run(["git", "tag", "-l", "--format=%(contents:subject)", tag])
282285
if c.err:
283286
return None
284287
return c.out.strip()
@@ -326,9 +329,19 @@ def _get_log_as_str_list(start: str | None, end: str, args: str) -> list[str]:
326329
delimiter = "----------commit-delimiter----------"
327330
log_format: str = "%H%n%P%n%s%n%an%n%ae%n%b"
328331
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}"
332+
cmd_args = [
333+
"git",
334+
"-c",
335+
"log.showSignature=False",
336+
"log",
337+
f"--pretty={log_format}{delimiter}",
338+
]
339+
if args:
340+
cmd_args.extend(args.split())
341+
if command_range:
342+
cmd_args.append(command_range)
330343

331-
c = cmd.run(command)
344+
c = cmd.run(cmd_args)
332345
if c.return_code != 0:
333346
raise GitCommandError(c.err)
334347
return c.out.split(f"{delimiter}\n")

tests/test_git.py

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import inspect
44
import os
5-
import platform
65
from typing import TYPE_CHECKING
76

87
import pytest
@@ -346,25 +345,23 @@ def test_create_tag_with_message(util: UtilFixture):
346345
tag_message = "test message"
347346
util.create_tag(tag_name, tag_message)
348347
assert git.get_latest_tag_name() == tag_name
349-
assert git.get_tag_message(tag_name) == (
350-
tag_message if platform.system() != "Windows" else f"'{tag_message}'"
351-
)
348+
assert git.get_tag_message(tag_name) == tag_message
352349

353350

354351
@pytest.mark.parametrize(
355352
("file_path", "expected_cmd"),
356353
[
357354
(
358355
"/tmp/temp file",
359-
'git commit --signoff -F "/tmp/temp file"',
356+
["git", "commit", "--signoff", "-F", "/tmp/temp file"],
360357
),
361358
(
362359
"/tmp dir/temp file",
363-
'git commit --signoff -F "/tmp dir/temp file"',
360+
["git", "commit", "--signoff", "-F", "/tmp dir/temp file"],
364361
),
365362
(
366363
"/tmp/tempfile",
367-
'git commit --signoff -F "/tmp/tempfile"',
364+
["git", "commit", "--signoff", "-F", "/tmp/tempfile"],
368365
),
369366
],
370367
ids=[
@@ -374,7 +371,7 @@ def test_create_tag_with_message(util: UtilFixture):
374371
],
375372
)
376373
def test_commit_with_spaces_in_path(
377-
mocker: MockFixture, file_path: str, expected_cmd: str, util: UtilFixture
374+
mocker: MockFixture, file_path: str, expected_cmd: list[str], util: UtilFixture
378375
):
379376
mock_run = util.mock_cmd()
380377
mock_unlink = mocker.patch("os.unlink")
@@ -383,7 +380,7 @@ def test_commit_with_spaces_in_path(
383380

384381
git.commit("feat: new feature", "--signoff")
385382

386-
mock_run.assert_called_once_with(expected_cmd)
383+
mock_run.assert_called_once_with(expected_cmd, env=None)
387384
mock_unlink.assert_called_once_with(file_path)
388385

389386

@@ -474,29 +471,39 @@ def test_git_commit_from_rev_and_commit(linebreak):
474471

475472

476473
@pytest.mark.parametrize(
477-
("os_name", "committer_date", "expected_cmd"),
474+
"committer_date",
478475
[
479-
(
480-
"nt",
481-
"2024-03-20",
482-
'cmd /v /c "set GIT_COMMITTER_DATE=2024-03-20&& git commit -F "temp.txt""',
483-
),
484-
(
485-
"posix",
486-
"2024-03-20",
487-
'GIT_COMMITTER_DATE=2024-03-20 git commit -F "temp.txt"',
488-
),
489-
("nt", None, 'git commit -F "temp.txt"'),
490-
("posix", None, 'git commit -F "temp.txt"'),
476+
"2024-03-20",
477+
None,
491478
],
492479
)
493-
def test_create_commit_cmd_string(
494-
mocker: MockFixture, os_name: str, committer_date: str, expected_cmd: str
495-
):
496-
"""Test the OS-specific behavior of _create_commit_cmd_string"""
497-
mocker.patch("os.name", os_name)
498-
result = git._create_commit_cmd_string("", committer_date, "temp.txt")
499-
assert result == expected_cmd
480+
def test_commit_uses_list_args_and_env(mocker: MockFixture, committer_date: str | None):
481+
"""Test that git.commit uses list-based subprocess (no shell injection)."""
482+
mock_run = mocker.patch("commitizen.cmd.run")
483+
mock_run.return_value = cmd.Command("", "", b"", b"", 0)
484+
485+
# We need to mock NamedTemporaryFile to control the file name
486+
mock_file = mocker.MagicMock()
487+
mock_file.name = "temp.txt"
488+
mock_file.__enter__ = mocker.MagicMock(return_value=mock_file)
489+
mock_file.__exit__ = mocker.MagicMock(return_value=False)
490+
mocker.patch("commitizen.git.NamedTemporaryFile", return_value=mock_file)
491+
mocker.patch("os.unlink")
492+
493+
git.commit("test message", args="-a --no-verify", committer_date=committer_date)
494+
495+
call_args = mock_run.call_args
496+
# First positional arg should be a list (no shell injection)
497+
cmd_list = call_args[0][0]
498+
assert isinstance(cmd_list, list)
499+
assert cmd_list == ["git", "commit", "-a", "--no-verify", "-F", "temp.txt"]
500+
501+
if committer_date:
502+
env = call_args[1]["env"]
503+
assert env == {"GIT_COMMITTER_DATE": committer_date}
504+
else:
505+
env = call_args[1]["env"]
506+
assert env is None
500507

501508

502509
def test_get_default_branch_success(util: UtilFixture):

0 commit comments

Comments
 (0)