Skip to content

Commit 345f1ec

Browse files
committed
fix: critical security and encoding vulnerabilities
- Fixed command injection vulnerabilities in release scripts - Enhanced UTF-8 encoding error handling with fallback strategies - Removed hardcoded credentials from release_uv.py - Improved git diff encoding with latin-1 fallback - Sanitized diff content before sending to OpenAI API
1 parent 2730c15 commit 345f1ec

File tree

4 files changed

+94
-44
lines changed

4 files changed

+94
-44
lines changed

commitloom/core/git.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,19 @@ def get_diff(files: list[GitFile] | None = None) -> str:
258258
# Get raw bytes to handle different encodings
259259
result = subprocess.run(cmd, capture_output=True, check=True)
260260

261-
# Try to decode with UTF-8 first, fallback to latin-1 with replacement
261+
# Try to decode with UTF-8 first, fallback to latin-1 which accepts any byte sequence
262262
try:
263263
return result.stdout.decode('utf-8')
264264
except UnicodeDecodeError:
265-
logger.warning("Failed to decode diff as UTF-8, using fallback encoding")
266-
# Use latin-1 which accepts any byte sequence, or replace errors
267-
return result.stdout.decode('utf-8', errors='replace')
265+
logger.warning("Failed to decode diff as UTF-8, using latin-1 encoding")
266+
# latin-1 (ISO-8859-1) accepts any byte sequence (0x00-0xFF)
267+
# This ensures we never fail with encoding errors
268+
try:
269+
return result.stdout.decode('latin-1')
270+
except Exception:
271+
# Ultimate fallback: replace invalid characters
272+
logger.warning("latin-1 decoding also failed, using replacement characters")
273+
return result.stdout.decode('utf-8', errors='replace')
268274

269275
except subprocess.CalledProcessError as e:
270276
error_msg = e.stderr.decode('utf-8', errors='replace') if e.stderr else str(e)

commitloom/services/ai_service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ def token_usage_from_api_usage(cls, usage: dict[str, int]) -> TokenUsage:
105105

106106
def generate_prompt(self, diff: str, changed_files: list[GitFile]) -> str:
107107
"""Generate the prompt for the AI model."""
108+
# Ensure diff is properly encoded by cleaning any invalid UTF-8 sequences
109+
if isinstance(diff, bytes):
110+
diff = diff.decode('utf-8', errors='replace')
111+
elif isinstance(diff, str):
112+
# Re-encode and decode to ensure clean UTF-8
113+
try:
114+
diff = diff.encode('utf-8', errors='replace').decode('utf-8')
115+
except Exception:
116+
# If encoding fails, use original string
117+
pass
118+
108119
files_summary = ", ".join(f.path for f in changed_files)
109120
has_binary = any(f.is_binary for f in changed_files)
110121
binary_files = ", ".join(f.path for f in changed_files if f.is_binary)

release.py

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,22 @@
2626
"chore": "🔧 Chores",
2727
}
2828

29-
def run_command(cmd: str) -> str:
30-
return subprocess.check_output(cmd, shell=True).decode().strip()
29+
def run_command(cmd: str | list[str]) -> str:
30+
"""Execute a command safely without shell=True.
31+
32+
Args:
33+
cmd: Command as list of arguments (preferred) or string (will attempt to parse)
34+
35+
Returns:
36+
Command output as string
37+
"""
38+
if isinstance(cmd, str):
39+
# For simple commands, split by spaces
40+
# Note: This won't work for commands with quoted arguments or shell operators
41+
# Those commands should be refactored to use list form
42+
import shlex
43+
cmd = shlex.split(cmd)
44+
return subprocess.check_output(cmd, shell=False).decode().strip()
3145

3246
def get_current_version() -> str:
3347
return run_command("poetry version -s")
@@ -77,11 +91,15 @@ def update_changelog(version: str) -> None:
7791
content = f.read()
7892

7993
# Get commits since last release
80-
last_tag = run_command("git describe --tags --abbrev=0 || echo ''")
94+
try:
95+
last_tag = run_command(["git", "describe", "--tags", "--abbrev=0"])
96+
except subprocess.CalledProcessError:
97+
last_tag = ""
98+
8199
if last_tag:
82-
raw_commits = run_command(f"git log {last_tag}..HEAD --pretty=format:'%s'").split('\n')
100+
raw_commits = run_command(["git", "log", f"{last_tag}..HEAD", "--pretty=format:%s"]).split('\n')
83101
else:
84-
raw_commits = run_command("git log --pretty=format:'%s'").split('\n')
102+
raw_commits = run_command(["git", "log", "--pretty=format:%s"]).split('\n')
85103

86104
# Categorize commits
87105
categorized_commits = categorize_commits(raw_commits)
@@ -117,16 +135,16 @@ def create_github_release(version: str, dry_run: bool = False) -> None:
117135
tag = f"v{version}"
118136
if not dry_run:
119137
# Create and push tag
120-
run_command(f'git tag -a {tag} -m "Release {tag}"')
121-
run_command("git push origin main --tags")
138+
run_command(["git", "tag", "-a", tag, "-m", f"Release {tag}"])
139+
run_command(["git", "push", "origin", "main", "--tags"])
122140
print(f"✅ Created and pushed tag {tag}")
123141

124142
# Create GitHub Release
125143
github_token = os.getenv("GITHUB_TOKEN")
126144
if github_token:
127145
try:
128146
# Get repository info from git remote
129-
remote_url = run_command("git remote get-url origin")
147+
remote_url = run_command(["git", "remote", "get-url", "origin"])
130148
repo_path = re.search(r"github\.com[:/](.+?)(?:\.git)?$", remote_url).group(1)
131149

132150
# Prepare release data
@@ -189,14 +207,14 @@ def create_version_commits(new_version: str) -> None:
189207
update_init_version(new_version)
190208

191209
# 2. Add both version files and commit
192-
run_command('git add pyproject.toml commitloom/__init__.py')
193-
run_command(f'git commit -m "build: bump version to {new_version}"')
210+
run_command(["git", "add", "pyproject.toml", "commitloom/__init__.py"])
211+
run_command(["git", "commit", "-m", f"build: bump version to {new_version}"])
194212
print("✅ Committed version bump")
195213

196214
# 3. Update changelog
197215
update_changelog(new_version)
198-
run_command('git add CHANGELOG.md')
199-
run_command(f'git commit -m "docs: update changelog for {new_version}"')
216+
run_command(["git", "add", "CHANGELOG.md"])
217+
run_command(["git", "commit", "-m", f"docs: update changelog for {new_version}"])
200218
print("✅ Committed changelog update")
201219

202220
def main() -> None:
@@ -215,13 +233,13 @@ def main() -> None:
215233
args = parser.parse_args()
216234

217235
# Ensure we're on main branch
218-
current_branch = run_command("git branch --show-current")
236+
current_branch = run_command(["git", "branch", "--show-current"])
219237
if current_branch != "main":
220238
print("❌ Must be on main branch to release")
221239
exit(1)
222240

223241
# Ensure working directory is clean
224-
if run_command("git status --porcelain"):
242+
if run_command(["git", "status", "--porcelain"]):
225243
print("❌ Working directory is not clean")
226244
exit(1)
227245

@@ -235,7 +253,7 @@ def main() -> None:
235253
create_version_commits(new_version)
236254

237255
# Push changes
238-
run_command("git push origin main")
256+
run_command(["git", "push", "origin", "main"])
239257
print("✅ Pushed changes to main")
240258

241259
# Create GitHub release

release_uv.py

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,26 @@
3030
"chore": "🔧 Chores",
3131
}
3232

33-
def run_command(cmd: str, check: bool = True) -> str:
34-
"""Run a shell command and return output."""
33+
def run_command(cmd: str | list[str], check: bool = True) -> str:
34+
"""Run a command safely without shell=True.
35+
36+
Args:
37+
cmd: Command as list of arguments (preferred) or string (will be parsed)
38+
check: Whether to raise exception on non-zero exit
39+
40+
Returns:
41+
Command output as string
42+
"""
43+
import shlex
44+
if isinstance(cmd, str):
45+
cmd = shlex.split(cmd)
46+
3547
try:
36-
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, check=check)
48+
result = subprocess.run(cmd, shell=False, capture_output=True, text=True, check=check)
3749
return result.stdout.strip()
3850
except subprocess.CalledProcessError as e:
3951
if check:
40-
print(f"❌ Command failed: {cmd}")
52+
print(f"❌ Command failed: {' '.join(cmd) if isinstance(cmd, list) else cmd}")
4153
print(f" Error: {e.stderr}")
4254
sys.exit(1)
4355
return ""
@@ -128,11 +140,15 @@ def update_changelog(version: str) -> None:
128140
current_date = datetime.now().strftime("%Y-%m-%d")
129141

130142
# Get commits since last tag
131-
last_tag = run_command("git describe --tags --abbrev=0 2>/dev/null || echo ''", check=False)
143+
try:
144+
last_tag = run_command(["git", "describe", "--tags", "--abbrev=0"], check=True)
145+
except Exception:
146+
last_tag = ""
147+
132148
if last_tag:
133-
raw_commits = run_command(f"git log {last_tag}..HEAD --pretty=format:'%s'").split('\n')
149+
raw_commits = run_command(["git", "log", f"{last_tag}..HEAD", "--pretty=format:%s"]).split('\n')
134150
else:
135-
raw_commits = run_command("git log --pretty=format:'%s'").split('\n')
151+
raw_commits = run_command(["git", "log", "--pretty=format:%s"]).split('\n')
136152

137153
# Categorize commits
138154
categorized_commits = categorize_commits(raw_commits)
@@ -177,14 +193,14 @@ def create_version_commits(new_version: str) -> None:
177193
update_version_in_files(new_version)
178194

179195
# Commit version bump
180-
run_command('git add pyproject.toml commitloom/__init__.py')
181-
run_command(f'git commit -m "build: bump version to {new_version}"')
196+
run_command(["git", "add", "pyproject.toml", "commitloom/__init__.py"])
197+
run_command(["git", "commit", "-m", f"build: bump version to {new_version}"])
182198
print("✅ Committed version bump")
183199

184200
# Update changelog
185201
update_changelog(new_version)
186-
run_command('git add CHANGELOG.md')
187-
run_command(f'git commit -m "docs: update changelog for {new_version}"')
202+
run_command(["git", "add", "CHANGELOG.md"])
203+
run_command(["git", "commit", "-m", f"docs: update changelog for {new_version}"])
188204
print("✅ Committed changelog update")
189205

190206
def get_changelog_entry(version: str) -> str:
@@ -215,22 +231,22 @@ def create_github_release(version: str, dry_run: bool = False) -> None:
215231
changelog_content = get_changelog_entry(version)
216232
tag_message = f"Release {tag}\n\n{changelog_content}" if changelog_content else f"Release {tag}"
217233

218-
run_command(f'git tag -a {tag} -m "{tag_message}"')
234+
run_command(["git", "tag", "-a", tag, "-m", tag_message])
219235
print(f"✅ Created tag {tag}")
220236

221237
# Push commits and tag
222-
run_command("git push origin main")
238+
run_command(["git", "push", "origin", "main"])
223239
print("✅ Pushed commits to main")
224240

225-
run_command("git push origin --tags")
241+
run_command(["git", "push", "origin", "--tags"])
226242
print("✅ Pushed tag to origin")
227243

228244
# Create GitHub Release via API
229245
github_token = os.getenv("GITHUB_TOKEN")
230246
if github_token:
231247
try:
232248
# Get repository info from git remote
233-
remote_url = run_command("git remote get-url origin")
249+
remote_url = run_command(["git", "remote", "get-url", "origin"])
234250
repo_match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", remote_url)
235251
if not repo_match:
236252
print("⚠️ Could not parse GitHub repository from remote URL")
@@ -276,25 +292,24 @@ def create_github_release(version: str, dry_run: bool = False) -> None:
276292
def check_prerequisites() -> None:
277293
"""Check that we can proceed with release."""
278294
# Ensure we're on main branch
279-
current_branch = run_command("git branch --show-current")
295+
current_branch = run_command(["git", "branch", "--show-current"])
280296
if current_branch != "main":
281297
print(f"❌ Must be on main branch to release (currently on {current_branch})")
282298
sys.exit(1)
283299

284300
# Ensure working directory is clean
285-
if run_command("git status --porcelain"):
301+
if run_command(["git", "status", "--porcelain"]):
286302
print("❌ Working directory is not clean. Commit or stash changes first.")
287303
sys.exit(1)
288304

289305
# Ensure git user is configured
290-
user_name = run_command("git config user.name", check=False)
291-
user_email = run_command("git config user.email", check=False)
306+
user_name = run_command(["git", "config", "user.name"], check=False)
307+
user_email = run_command(["git", "config", "user.email"], check=False)
292308
if not user_name or not user_email:
293-
print("⚠️ Git user not configured. Setting default values...")
294-
if not user_name:
295-
run_command('git config user.name "Petru Arakiss"')
296-
if not user_email:
297-
run_command('git config user.email "petruarakiss@gmail.com"')
309+
print("❌ Git user not configured. Please configure git user:")
310+
print(" git config user.name 'Your Name'")
311+
print(" git config user.email 'your.email@example.com'")
312+
sys.exit(1)
298313

299314
def main() -> None:
300315
parser = argparse.ArgumentParser(
@@ -350,7 +365,7 @@ def main() -> None:
350365
tag = f"v{new_version}"
351366
changelog_content = get_changelog_entry(new_version)
352367
tag_message = f"Release {tag}\n\n{changelog_content}" if changelog_content else f"Release {tag}"
353-
run_command(f'git tag -a {tag} -m "{tag_message}"')
368+
run_command(["git", "tag", "-a", tag, "-m", tag_message])
354369
print(f"✅ Created tag {tag}")
355370
print("ℹ️ Skipped GitHub release (use --skip-github=false to enable)")
356371

0 commit comments

Comments
 (0)