11import process from 'node:process'
2- import { exec } from 'node:child_process'
2+ import { execFile } from 'node:child_process'
33import { promisify } from 'node:util'
4+ import validateNpmPackageName from 'validate-npm-package-name'
45import { logCommand , logSuccess , logError } from './logger.ts'
56
6- const execAsync = promisify ( exec )
7+ const execFileAsync = promisify ( execFile )
8+
9+ // Validation pattern for npm usernames/org names
10+ // These follow similar rules: lowercase alphanumeric with hyphens, can't start/end with hyphen
11+ const NPM_USERNAME_RE = / ^ [ a - z 0 - 9 ] ( [ a - z 0 - 9 - ] * [ a - z 0 - 9 ] ) ? $ / i
12+
13+ /**
14+ * Validates an npm package name using the official npm validation package
15+ * @throws Error if the name is invalid
16+ */
17+ export function validatePackageName ( name : string ) : void {
18+ const result = validateNpmPackageName ( name )
19+ if ( ! result . validForNewPackages && ! result . validForOldPackages ) {
20+ const errors = result . errors || result . warnings || [ 'Invalid package name' ]
21+ throw new Error ( `Invalid package name "${ name } ": ${ errors . join ( ', ' ) } ` )
22+ }
23+ }
24+
25+ /**
26+ * Validates an npm username
27+ * @throws Error if the username is invalid
28+ */
29+ export function validateUsername ( name : string ) : void {
30+ if ( ! name || name . length > 50 || ! NPM_USERNAME_RE . test ( name ) ) {
31+ throw new Error ( `Invalid username: ${ name } ` )
32+ }
33+ }
34+
35+ /**
36+ * Validates an npm org name (without the @ prefix)
37+ * @throws Error if the org name is invalid
38+ */
39+ export function validateOrgName ( name : string ) : void {
40+ if ( ! name || name . length > 50 || ! NPM_USERNAME_RE . test ( name ) ) {
41+ throw new Error ( `Invalid org name: ${ name } ` )
42+ }
43+ }
44+
45+ /**
46+ * Validates a scope:team format (e.g., @myorg:developers)
47+ * @throws Error if the scope:team is invalid
48+ */
49+ export function validateScopeTeam ( scopeTeam : string ) : void {
50+ if ( ! scopeTeam || scopeTeam . length > 100 ) {
51+ throw new Error ( `Invalid scope:team: ${ scopeTeam } ` )
52+ }
53+ // Format: @scope :team
54+ const match = scopeTeam . match ( / ^ @ ( [ ^ : ] + ) : ( .+ ) $ / )
55+ if ( ! match ) {
56+ throw new Error ( `Invalid scope:team format: ${ scopeTeam } ` )
57+ }
58+ const [ , scope , team ] = match
59+ if ( ! scope || ! NPM_USERNAME_RE . test ( scope ) ) {
60+ throw new Error ( `Invalid scope in scope:team: ${ scopeTeam } ` )
61+ }
62+ if ( ! team || ! NPM_USERNAME_RE . test ( team ) ) {
63+ throw new Error ( `Invalid team name in scope:team: ${ scopeTeam } ` )
64+ }
65+ }
766
867export interface NpmExecResult {
968 stdout : string
@@ -56,20 +115,21 @@ export async function execNpm(
56115 args : string [ ] ,
57116 options : { otp ?: string ; silent ?: boolean } = { } ,
58117) : Promise < NpmExecResult > {
59- const cmd = [ 'npm' , ...args ]
60-
61- if ( options . otp ) {
62- cmd . push ( '--otp' , options . otp )
63- }
118+ // Build the full args array including OTP if provided
119+ const npmArgs = options . otp ? [ ...args , '--otp' , options . otp ] : args
64120
65121 // Log the command being run (hide OTP value for security)
66122 if ( ! options . silent ) {
67- const displayCmd = options . otp ? [ 'npm' , ...args , '--otp' , '******' ] . join ( ' ' ) : cmd . join ( ' ' )
123+ const displayCmd = options . otp
124+ ? [ 'npm' , ...args , '--otp' , '******' ] . join ( ' ' )
125+ : [ 'npm' , ...args ] . join ( ' ' )
68126 logCommand ( displayCmd )
69127 }
70128
71129 try {
72- const { stdout, stderr } = await execAsync ( cmd . join ( ' ' ) , {
130+ // Use execFile instead of exec to avoid shell injection vulnerabilities
131+ // execFile does not spawn a shell, so metacharacters are passed literally
132+ const { stdout, stderr } = await execFileAsync ( 'npm' , npmArgs , {
73133 timeout : 60000 ,
74134 env : { ...process . env , FORCE_COLOR : '0' } ,
75135 } )
@@ -127,6 +187,8 @@ export async function orgAddUser(
127187 role : 'developer' | 'admin' | 'owner' ,
128188 otp ?: string ,
129189) : Promise < NpmExecResult > {
190+ validateOrgName ( org )
191+ validateUsername ( user )
130192 return execNpm ( [ 'org' , 'set' , org , user , role ] , { otp } )
131193}
132194
@@ -135,14 +197,18 @@ export async function orgRemoveUser(
135197 user : string ,
136198 otp ?: string ,
137199) : Promise < NpmExecResult > {
200+ validateOrgName ( org )
201+ validateUsername ( user )
138202 return execNpm ( [ 'org' , 'rm' , org , user ] , { otp } )
139203}
140204
141205export async function teamCreate ( scopeTeam : string , otp ?: string ) : Promise < NpmExecResult > {
206+ validateScopeTeam ( scopeTeam )
142207 return execNpm ( [ 'team' , 'create' , scopeTeam ] , { otp } )
143208}
144209
145210export async function teamDestroy ( scopeTeam : string , otp ?: string ) : Promise < NpmExecResult > {
211+ validateScopeTeam ( scopeTeam )
146212 return execNpm ( [ 'team' , 'destroy' , scopeTeam ] , { otp } )
147213}
148214
@@ -151,6 +217,8 @@ export async function teamAddUser(
151217 user : string ,
152218 otp ?: string ,
153219) : Promise < NpmExecResult > {
220+ validateScopeTeam ( scopeTeam )
221+ validateUsername ( user )
154222 return execNpm ( [ 'team' , 'add' , scopeTeam , user ] , { otp } )
155223}
156224
@@ -159,6 +227,8 @@ export async function teamRemoveUser(
159227 user : string ,
160228 otp ?: string ,
161229) : Promise < NpmExecResult > {
230+ validateScopeTeam ( scopeTeam )
231+ validateUsername ( user )
162232 return execNpm ( [ 'team' , 'rm' , scopeTeam , user ] , { otp } )
163233}
164234
@@ -168,6 +238,8 @@ export async function accessGrant(
168238 pkg : string ,
169239 otp ?: string ,
170240) : Promise < NpmExecResult > {
241+ validateScopeTeam ( scopeTeam )
242+ validatePackageName ( pkg )
171243 return execNpm ( [ 'access' , 'grant' , permission , scopeTeam , pkg ] , { otp } )
172244}
173245
@@ -176,31 +248,41 @@ export async function accessRevoke(
176248 pkg : string ,
177249 otp ?: string ,
178250) : Promise < NpmExecResult > {
251+ validateScopeTeam ( scopeTeam )
252+ validatePackageName ( pkg )
179253 return execNpm ( [ 'access' , 'revoke' , scopeTeam , pkg ] , { otp } )
180254}
181255
182256export async function ownerAdd ( user : string , pkg : string , otp ?: string ) : Promise < NpmExecResult > {
257+ validateUsername ( user )
258+ validatePackageName ( pkg )
183259 return execNpm ( [ 'owner' , 'add' , user , pkg ] , { otp } )
184260}
185261
186262export async function ownerRemove ( user : string , pkg : string , otp ?: string ) : Promise < NpmExecResult > {
263+ validateUsername ( user )
264+ validatePackageName ( pkg )
187265 return execNpm ( [ 'owner' , 'rm' , user , pkg ] , { otp } )
188266}
189267
190268// List functions (for reading data) - silent since they're not user-triggered operations
191269
192270export async function orgListUsers ( org : string ) : Promise < NpmExecResult > {
271+ validateOrgName ( org )
193272 return execNpm ( [ 'org' , 'ls' , org , '--json' ] , { silent : true } )
194273}
195274
196275export async function teamListTeams ( org : string ) : Promise < NpmExecResult > {
276+ validateOrgName ( org )
197277 return execNpm ( [ 'team' , 'ls' , org , '--json' ] , { silent : true } )
198278}
199279
200280export async function teamListUsers ( scopeTeam : string ) : Promise < NpmExecResult > {
281+ validateScopeTeam ( scopeTeam )
201282 return execNpm ( [ 'team' , 'ls' , scopeTeam , '--json' ] , { silent : true } )
202283}
203284
204285export async function accessListCollaborators ( pkg : string ) : Promise < NpmExecResult > {
286+ validatePackageName ( pkg )
205287 return execNpm ( [ 'access' , 'list' , 'collaborators' , pkg , '--json' ] , { silent : true } )
206288}
0 commit comments