Skip to content

Commit 666246b

Browse files
committed
fix(cli): address security vulnerabilities in release notes generator
- Replace execSync with execFileSync to prevent shell injection - Add input validation for git refs (GIT_REF_PATTERN) - Add validation for git SHA hashes (GIT_SHA_PATTERN) - Use null byte delimiter instead of string delimiter to prevent parsing issues if commit message contains delimiter string - Remove redundant --oneline option from git log command Addresses CRITICAL/HIGH findings from REV code review.
1 parent 10ef385 commit 666246b

File tree

1 file changed

+44
-18
lines changed

1 file changed

+44
-18
lines changed

cli/scripts/generate-release-notes.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,22 @@
1616
* --output <file> Write to file instead of stdout
1717
*/
1818

19-
import * as childProcess from "child_process";
19+
import { execFileSync } from "child_process";
2020
import * as fs from "fs";
2121

22+
// Valid git ref pattern: alphanumeric, dots, hyphens, underscores, slashes, tildes, carets
23+
const GIT_REF_PATTERN = /^[a-zA-Z0-9._~^/+-]+$/;
24+
// Valid git SHA pattern: 7-40 hex characters
25+
const GIT_SHA_PATTERN = /^[a-f0-9]{7,40}$/i;
26+
27+
function isValidGitRef(ref: string): boolean {
28+
return GIT_REF_PATTERN.test(ref) && !ref.includes("..");
29+
}
30+
31+
function isValidGitSha(sha: string): boolean {
32+
return GIT_SHA_PATTERN.test(sha);
33+
}
34+
2235
// Conventional commit types and their display names
2336
const COMMIT_TYPES: Record<string, { title: string; emoji: string; priority: number }> = {
2437
feat: { title: "New Features", emoji: "🚀", priority: 1 },
@@ -64,9 +77,9 @@ interface ReleaseNotes {
6477
};
6578
}
6679

67-
function execSync(command: string): string {
80+
function gitExec(args: string[]): string {
6881
try {
69-
const result = childProcess.execSync(command, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
82+
const result = execFileSync("git", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
7083
return result.trim();
7184
} catch (err) {
7285
return "";
@@ -135,7 +148,7 @@ Examples:
135148

136149
function detectLastTag(): string {
137150
// Try to find the last version tag
138-
const tags = execSync("git tag --sort=-version:refname 2>/dev/null").split("\n").filter(Boolean);
151+
const tags = gitExec(["tag", "--sort=-version:refname"]).split("\n").filter(Boolean);
139152

140153
// Look for semantic version tags
141154
for (const tag of tags) {
@@ -145,36 +158,49 @@ function detectLastTag(): string {
145158
}
146159

147160
// Fallback: find a meaningful starting point from commit messages
148-
const versionCommit = execSync("git log --oneline --grep='prepare-for-0.14\\|0.13\\|release' --format='%H' | head -1");
149-
if (versionCommit) {
150-
return versionCommit;
161+
const versionCommits = gitExec(["log", "--grep=prepare-for-0.14\\|0.13\\|release", "--format=%H"]).split("\n").filter(Boolean);
162+
if (versionCommits.length > 0 && versionCommits[0]) {
163+
return versionCommits[0];
151164
}
152165

153166
// Last resort: 100 commits back
154167
return "HEAD~100";
155168
}
156169

157170
function getCommitsBetween(since: string, until: string): string[] {
171+
// Validate refs to prevent command injection
172+
if (since && !isValidGitRef(since)) {
173+
throw new Error(`Invalid git ref: ${since}`);
174+
}
175+
if (!isValidGitRef(until)) {
176+
throw new Error(`Invalid git ref: ${until}`);
177+
}
178+
158179
// Get commit hashes between the two refs
159180
const range = since ? `${since}..${until}` : until;
160-
const output = execSync(`git log ${range} --format='%H' --no-merges 2>/dev/null`);
181+
const output = gitExec(["log", range, "--format=%H", "--no-merges"]);
161182
return output.split("\n").filter(Boolean);
162183
}
163184

164185
function parseCommit(hash: string): ParsedCommit | null {
165-
const format = "%H%n%h%n%s%n%b%n%ad%n%an%n---END---";
166-
const output = execSync(`git log -1 --format='${format}' --date=short ${hash}`);
186+
// Validate hash to prevent command injection
187+
if (!isValidGitSha(hash)) {
188+
return null;
189+
}
190+
191+
// Use null byte as delimiter (unlikely to appear in commit messages)
192+
const format = "%H%x00%h%x00%s%x00%b%x00%ad%x00%an";
193+
const output = gitExec(["log", "-1", `--format=${format}`, "--date=short", hash]);
167194

168195
if (!output) return null;
169196

170-
const parts = output.split("\n---END---")[0]?.split("\n") || [];
171-
const [fullHash, shortHash, subject, ...rest] = parts;
172-
const author = rest.pop() || "";
173-
const date = rest.pop() || "";
174-
const body = rest.join("\n").trim();
197+
const parts = output.split("\x00");
198+
const [fullHash, shortHash, subject, body, date, author] = parts;
175199

176200
if (!subject) return null;
177201

202+
const trimmedBody = (body || "").trim();
203+
178204
// Parse conventional commit format: type(scope): subject
179205
// Also handle: type: subject, type!: subject (breaking)
180206
const match = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
@@ -187,7 +213,7 @@ function parseCommit(hash: string): ParsedCommit | null {
187213
if (match) {
188214
type = match[1]?.toLowerCase() || "other";
189215
scope = match[2] || null;
190-
breaking = !!match[3] || body.includes("BREAKING CHANGE");
216+
breaking = !!match[3] || trimmedBody.includes("BREAKING CHANGE");
191217
cleanSubject = match[4] || subject;
192218
}
193219

@@ -201,10 +227,10 @@ function parseCommit(hash: string): ParsedCommit | null {
201227
type,
202228
scope,
203229
subject: cleanSubject,
204-
body,
230+
body: trimmedBody,
205231
breaking,
206232
date: date || new Date().toISOString().split("T")[0] || "",
207-
author,
233+
author: (author || "").trim(),
208234
};
209235
}
210236

0 commit comments

Comments
 (0)