1616 * --output <file> Write to file instead of stdout
1717 */
1818
19- import * as childProcess from "child_process" ;
19+ import { execFileSync } from "child_process" ;
2020import * as fs from "fs" ;
2121
22+ // Valid git ref pattern: alphanumeric, dots, hyphens, underscores, slashes, tildes, carets
23+ const GIT_REF_PATTERN = / ^ [ a - z A - Z 0 - 9 . _ ~ ^ / + - ] + $ / ;
24+ // Valid git SHA pattern: 7-40 hex characters
25+ const GIT_SHA_PATTERN = / ^ [ a - f 0 - 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
2336const 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
136149function 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
157170function 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
164185function 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