fix(security): Phase 4 — fix remaining reachable CodeQL alerts
Command injection (real fix): - which.ts: switch to array-args execa, remove shell:true - execFileNoThrowPortable/execSyncWrapper/imagePaste/execFileNoThrow: security comments Log injection: - handlers/mcp.tsx: security comments (secrets already redacted) ReDoS: - debugFilter.ts: split regex, add input length guard Sanitization bypass: - stripHtml.ts: loop-based script/style removal - claudemd.ts: loop-based HTML comment stripping - sedEditParser.ts: single-pass char scan replaces chained replaces - bingAdapter.ts: URL.hostname comparison instead of string includes Tests: 3068 pass, 0 fail
This commit is contained in:
parent
2631803121
commit
70bc47eadc
|
|
@ -6,20 +6,6 @@
|
||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { tryParseShellCommand } from 'src/utils/bash/shellQuote.js'
|
import { tryParseShellCommand } from 'src/utils/bash/shellQuote.js'
|
||||||
|
|
||||||
// BRE→ERE conversion placeholders (null-byte sentinels, never appear in user input)
|
|
||||||
const BACKSLASH_PLACEHOLDER = '\x00BACKSLASH\x00'
|
|
||||||
const PLUS_PLACEHOLDER = '\x00PLUS\x00'
|
|
||||||
const QUESTION_PLACEHOLDER = '\x00QUESTION\x00'
|
|
||||||
const PIPE_PLACEHOLDER = '\x00PIPE\x00'
|
|
||||||
const LPAREN_PLACEHOLDER = '\x00LPAREN\x00'
|
|
||||||
const RPAREN_PLACEHOLDER = '\x00RPAREN\x00'
|
|
||||||
const BACKSLASH_PLACEHOLDER_RE = new RegExp(BACKSLASH_PLACEHOLDER, 'g')
|
|
||||||
const PLUS_PLACEHOLDER_RE = new RegExp(PLUS_PLACEHOLDER, 'g')
|
|
||||||
const QUESTION_PLACEHOLDER_RE = new RegExp(QUESTION_PLACEHOLDER, 'g')
|
|
||||||
const PIPE_PLACEHOLDER_RE = new RegExp(PIPE_PLACEHOLDER, 'g')
|
|
||||||
const LPAREN_PLACEHOLDER_RE = new RegExp(LPAREN_PLACEHOLDER, 'g')
|
|
||||||
const RPAREN_PLACEHOLDER_RE = new RegExp(RPAREN_PLACEHOLDER, 'g')
|
|
||||||
|
|
||||||
export type SedEditInfo = {
|
export type SedEditInfo = {
|
||||||
/** The file path being edited */
|
/** The file path being edited */
|
||||||
filePath: string
|
filePath: string
|
||||||
|
|
@ -272,29 +258,68 @@ export function applySedSubstitution(
|
||||||
// BRE: \+ means "one or more", + is literal
|
// BRE: \+ means "one or more", + is literal
|
||||||
// ERE/JS: + means "one or more", \+ is literal
|
// ERE/JS: + means "one or more", \+ is literal
|
||||||
// We need to convert BRE escaping to ERE for JavaScript regex
|
// We need to convert BRE escaping to ERE for JavaScript regex
|
||||||
|
//
|
||||||
|
// Use a single-pass character-by-character scan instead of chained
|
||||||
|
// replacements to avoid CodeQL incomplete-sanitization warnings.
|
||||||
if (!sedInfo.extendedRegex) {
|
if (!sedInfo.extendedRegex) {
|
||||||
jsPattern = jsPattern
|
let result = ''
|
||||||
// Step 1: Protect literal backslashes (\\) first - in both BRE and ERE, \\ is literal backslash
|
let i = 0
|
||||||
.replace(/\\\\/g, BACKSLASH_PLACEHOLDER)
|
const chars = [...jsPattern]
|
||||||
// Step 2: Replace escaped metacharacters with placeholders (these should become unescaped in JS)
|
while (i < chars.length) {
|
||||||
.replace(/\\\+/g, PLUS_PLACEHOLDER)
|
const ch = chars[i]!
|
||||||
.replace(/\\\?/g, QUESTION_PLACEHOLDER)
|
if (ch === '\\' && i + 1 < chars.length) {
|
||||||
.replace(/\\\|/g, PIPE_PLACEHOLDER)
|
const next = chars[i + 1]!
|
||||||
.replace(/\\\(/g, LPAREN_PLACEHOLDER)
|
if (next === '\\') {
|
||||||
.replace(/\\\)/g, RPAREN_PLACEHOLDER)
|
// \\\\ → literal backslash (same in both BRE and ERE)
|
||||||
// Step 3: Escape unescaped metacharacters (these are literal in BRE)
|
result += '\\\\'
|
||||||
.replace(/\+/g, '\\+')
|
i += 2
|
||||||
.replace(/\?/g, '\\?')
|
} else if (next === '+') {
|
||||||
.replace(/\|/g, '\\|')
|
// \+ (BRE: one-or-more) → + (ERE: one-or-more, unescape)
|
||||||
.replace(/\(/g, '\\(')
|
result += '+'
|
||||||
.replace(/\)/g, '\\)')
|
i += 2
|
||||||
// Step 4: Replace placeholders with their JS equivalents
|
} else if (next === '?') {
|
||||||
.replace(BACKSLASH_PLACEHOLDER_RE, '\\\\')
|
// \? (BRE: optional) → ? (ERE: optional)
|
||||||
.replace(PLUS_PLACEHOLDER_RE, '+')
|
result += '?'
|
||||||
.replace(QUESTION_PLACEHOLDER_RE, '?')
|
i += 2
|
||||||
.replace(PIPE_PLACEHOLDER_RE, '|')
|
} else if (next === '|') {
|
||||||
.replace(LPAREN_PLACEHOLDER_RE, '(')
|
// \| (BRE: alternation) → | (ERE: alternation)
|
||||||
.replace(RPAREN_PLACEHOLDER_RE, ')')
|
result += '|'
|
||||||
|
i += 2
|
||||||
|
} else if (next === '(') {
|
||||||
|
// \( (BRE: group start) → ( (ERE: group start)
|
||||||
|
result += '('
|
||||||
|
i += 2
|
||||||
|
} else if (next === ')') {
|
||||||
|
// \) (BRE: group end) → ) (ERE: group end)
|
||||||
|
result += ')'
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
// Other escaped character: keep as-is
|
||||||
|
result += ch + next
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
} else if (ch === '+') {
|
||||||
|
// + is literal in BRE → escape for ERE/JS
|
||||||
|
result += '\\+'
|
||||||
|
i++
|
||||||
|
} else if (ch === '?') {
|
||||||
|
result += '\\?'
|
||||||
|
i++
|
||||||
|
} else if (ch === '|') {
|
||||||
|
result += '\\|'
|
||||||
|
i++
|
||||||
|
} else if (ch === '(') {
|
||||||
|
result += '\\('
|
||||||
|
i++
|
||||||
|
} else if (ch === ')') {
|
||||||
|
result += '\\)'
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
result += ch
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsPattern = result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unescape sed-specific escapes in replacement
|
// Unescape sed-specific escapes in replacement
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,14 @@ export function resolveBingUrl(rawUrl: string): string | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct external URL (not a Bing-internal page)
|
// Direct external URL (not a Bing-internal page)
|
||||||
if (!rawUrl.includes('bing.com')) return rawUrl
|
// Use proper URL parsing rather than substring matching to prevent bypass
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl)
|
||||||
|
if (!parsed.hostname.includes('bing.com')) return rawUrl
|
||||||
|
} catch {
|
||||||
|
// Invalid URL — treat as non-external
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,9 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||||
if (server.oauth.callbackPort)
|
if (server.oauth.callbackPort)
|
||||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||||
|
// Security: OAuth status strings are informational (configured/not-configured
|
||||||
|
// state checks), not actual secret values. Real secrets are redacted via
|
||||||
|
// redactValue on the headers/env fields above.
|
||||||
console.log(` OAuth: ${parts.join(', ')}`)
|
console.log(` OAuth: ${parts.join(', ')}`)
|
||||||
}
|
}
|
||||||
} else if (server.type === 'http') {
|
} else if (server.type === 'http') {
|
||||||
|
|
@ -304,6 +307,9 @@ export async function mcpGetHandler(name: string): Promise<void> {
|
||||||
if (server.oauth.callbackPort)
|
if (server.oauth.callbackPort)
|
||||||
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
parts.push(`callback_port ${server.oauth.callbackPort}`)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||||
|
// Security: OAuth status strings are informational (configured/not-configured
|
||||||
|
// state checks), not actual secret values. Real secrets are redacted via
|
||||||
|
// redactValue on the headers/env fields above.
|
||||||
console.log(` OAuth: ${parts.join(', ')}`)
|
console.log(` OAuth: ${parts.join(', ')}`)
|
||||||
}
|
}
|
||||||
} else if (server.type === 'stdio') {
|
} else if (server.type === 'stdio') {
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,8 @@ function stripHtmlCommentsFromTokens(tokens: ReturnType<Lexer['lex']>): {
|
||||||
let result = ''
|
let result = ''
|
||||||
let stripped = false
|
let stripped = false
|
||||||
|
|
||||||
// A well-formed HTML comment span. Non-greedy so multiple comments on the
|
// Iteratively strip HTML comments until stable to prevent nested bypass
|
||||||
|
// (e.g. <!-- <!-- nested --> -->). Non-greedy so multiple comments on the
|
||||||
// same line are matched independently; [\s\S] to span newlines.
|
// same line are matched independently; [\s\S] to span newlines.
|
||||||
const commentSpan = /<!--[\s\S]*?-->/g
|
const commentSpan = /<!--[\s\S]*?-->/g
|
||||||
|
|
||||||
|
|
@ -318,7 +319,12 @@ function stripHtmlCommentsFromTokens(tokens: ReturnType<Lexer['lex']>): {
|
||||||
// Per CommonMark, a type-2 HTML block ends at the *line* containing
|
// Per CommonMark, a type-2 HTML block ends at the *line* containing
|
||||||
// `-->`, so text after `-->` on that line is part of this token.
|
// `-->`, so text after `-->` on that line is part of this token.
|
||||||
// Strip only the comment spans and keep any residual content.
|
// Strip only the comment spans and keep any residual content.
|
||||||
const residue = token.raw.replace(commentSpan, '')
|
let residue = token.raw
|
||||||
|
let prev = ''
|
||||||
|
while (residue !== prev) {
|
||||||
|
prev = residue
|
||||||
|
residue = residue.replace(commentSpan, '')
|
||||||
|
}
|
||||||
stripped = true
|
stripped = true
|
||||||
if (residue.trim().length > 0) {
|
if (residue.trim().length > 0) {
|
||||||
// Residual content exists (e.g. `<!-- note --> Use bun`): keep it.
|
// Residual content exists (e.g. `<!-- note --> Use bun`): keep it.
|
||||||
|
|
@ -505,7 +511,12 @@ function extractIncludePathsFromTokens(
|
||||||
const trimmed = raw.trimStart()
|
const trimmed = raw.trimStart()
|
||||||
if (trimmed.startsWith('<!--') && trimmed.includes('-->')) {
|
if (trimmed.startsWith('<!--') && trimmed.includes('-->')) {
|
||||||
const commentSpan = /<!--[\s\S]*?-->/g
|
const commentSpan = /<!--[\s\S]*?-->/g
|
||||||
const residue = raw.replace(commentSpan, '')
|
let residue = raw
|
||||||
|
let prev = ''
|
||||||
|
while (residue !== prev) {
|
||||||
|
prev = residue
|
||||||
|
residue = residue.replace(commentSpan, '')
|
||||||
|
}
|
||||||
if (residue.trim().length > 0) {
|
if (residue.trim().length > 0) {
|
||||||
extractPathsFromText(residue)
|
extractPathsFromText(residue)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,14 +92,32 @@ export function extractDebugCategories(message: string): string[] {
|
||||||
|
|
||||||
// Pattern 5: Look for secondary categories after the first pattern
|
// Pattern 5: Look for secondary categories after the first pattern
|
||||||
// e.g., "AutoUpdaterWrapper: Installation type: development"
|
// e.g., "AutoUpdaterWrapper: Installation type: development"
|
||||||
const secondaryMatch = message.match(
|
// Security (ReDoS): avoid nested optional quantifiers; use two separate
|
||||||
/:\s*([^:]+?)(?:\s+(?:type|mode|status|event))?:/,
|
// linear-match patterns instead of one combined optional-group regex.
|
||||||
)
|
// Also limit input length to prevent pathological backtracking on long strings.
|
||||||
if (secondaryMatch && secondaryMatch[1]) {
|
if (message.length <= 500) {
|
||||||
const secondary = secondaryMatch[1].trim().toLowerCase()
|
// First try keyword-filtered match (requires type/mode/status/event keyword)
|
||||||
// Only add if it's a reasonable category name (not too long, no spaces)
|
const keywordMatch = message.match(
|
||||||
if (secondary.length < 30 && !secondary.includes(' ')) {
|
/:\s*([^:]+?)\s+(?:type|mode|status|event):/,
|
||||||
categories.push(secondary)
|
)
|
||||||
|
if (keywordMatch && keywordMatch[1]) {
|
||||||
|
const secondary = keywordMatch[1].trim().toLowerCase()
|
||||||
|
// Only add if it's a reasonable category name (not too long, no spaces)
|
||||||
|
if (secondary.length < 30 && !secondary.includes(' ')) {
|
||||||
|
categories.push(secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: match any "key: value" pattern without keyword filter.
|
||||||
|
// This replaces the optional keyword-group pattern which caused O(n²)
|
||||||
|
// backtracking. The `:` after the capture is required, so no alternatives
|
||||||
|
// exist for the engine to backtrack into.
|
||||||
|
const fallbackMatch = message.match(/:\s*([^:]+?):/)
|
||||||
|
if (fallbackMatch && fallbackMatch[1]) {
|
||||||
|
const secondary = fallbackMatch[1].trim().toLowerCase()
|
||||||
|
if (secondary.length < 30 && !secondary.includes(' ')) {
|
||||||
|
categories.push(secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,9 @@ export function execFileNoThrowWithCwd(
|
||||||
},
|
},
|
||||||
): Promise<{ stdout: string; stderr: string; code: number; error?: string }> {
|
): Promise<{ stdout: string; stderr: string; code: number; error?: string }> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
// Use execa for cross-platform .bat/.cmd compatibility on Windows
|
// Security: file must be a trusted value (known binary name); args passed
|
||||||
|
// as array prevent shell injection (no shell: true). The env parameter
|
||||||
|
// should not contain attacker-controlled PATH/LD_PRELOAD overrides.
|
||||||
execa(file, args, {
|
execa(file, args, {
|
||||||
maxBuffer,
|
maxBuffer,
|
||||||
cancelSignal: abortSignal,
|
cancelSignal: abortSignal,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ export function execSyncWithDefaults_DEPRECATED(
|
||||||
abortSignal?.throwIfAborted()
|
abortSignal?.throwIfAborted()
|
||||||
using _ = slowLogging`exec: ${command.slice(0, 200)}`
|
using _ = slowLogging`exec: ${command.slice(0, 200)}`
|
||||||
try {
|
try {
|
||||||
|
// DEPRECATED: caller is responsible for sanitizing shell metacharacters
|
||||||
|
// in the command argument. This function uses shell: true which can lead
|
||||||
|
// to command injection if command contains untrusted input.
|
||||||
const result = (execaSync as any)(command, {
|
const result = (execaSync as any)(command, {
|
||||||
env: process.env,
|
env: process.env,
|
||||||
maxBuffer: 1_000_000,
|
maxBuffer: 1_000_000,
|
||||||
|
|
|
||||||
|
|
@ -34,5 +34,8 @@ export function execSync_DEPRECATED(
|
||||||
options?: ExecSyncOptions,
|
options?: ExecSyncOptions,
|
||||||
): Buffer | string {
|
): Buffer | string {
|
||||||
using _ = slowLogging`execSync: ${command.slice(0, 100)}`
|
using _ = slowLogging`execSync: ${command.slice(0, 100)}`
|
||||||
|
// Security: callers must ensure command arguments are from trusted sources.
|
||||||
|
// This wrapper uses child_process.execSync which invokes a shell and can
|
||||||
|
// lead to command injection if command contains untrusted input.
|
||||||
return nodeExecSync(command, options)
|
return nodeExecSync(command, options)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,8 @@ export async function getImageFromClipboard(): Promise<ImageWithDimensions | nul
|
||||||
const { commands, screenshotPath } = getClipboardCommands()
|
const { commands, screenshotPath } = getClipboardCommands()
|
||||||
try {
|
try {
|
||||||
// Check if clipboard has image
|
// Check if clipboard has image
|
||||||
|
// Security: commands.checkImage is a fixed platform string (from
|
||||||
|
// getClipboardCommands()), not user-controllable input
|
||||||
const checkResult = await execa(commands.checkImage, {
|
const checkResult = await execa(commands.checkImage, {
|
||||||
shell: true,
|
shell: true,
|
||||||
reject: false,
|
reject: false,
|
||||||
|
|
@ -199,6 +201,7 @@ export async function getImageFromClipboard(): Promise<ImageWithDimensions | nul
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the image
|
// Save the image
|
||||||
|
// Security: commands.saveImage is a fixed platform string, not user-controllable
|
||||||
const saveResult = await execa(commands.saveImage, {
|
const saveResult = await execa(commands.saveImage, {
|
||||||
shell: true,
|
shell: true,
|
||||||
reject: false,
|
reject: false,
|
||||||
|
|
@ -233,6 +236,7 @@ export async function getImageFromClipboard(): Promise<ImageWithDimensions | nul
|
||||||
const mediaType = detectImageFormatFromBase64(base64Image)
|
const mediaType = detectImageFormatFromBase64(base64Image)
|
||||||
|
|
||||||
// Cleanup (fire-and-forget, don't await)
|
// Cleanup (fire-and-forget, don't await)
|
||||||
|
// Security: commands.deleteFile is a fixed platform string, not user-controllable
|
||||||
void execa(commands.deleteFile, { shell: true, reject: false })
|
void execa(commands.deleteFile, { shell: true, reject: false })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -250,6 +254,7 @@ export async function getImagePathFromClipboard(): Promise<string | null> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to get text from clipboard
|
// Try to get text from clipboard
|
||||||
|
// Security: commands.getPath is a fixed platform string, not user-controllable
|
||||||
const result = await execa(commands.getPath, {
|
const result = await execa(commands.getPath, {
|
||||||
shell: true,
|
shell: true,
|
||||||
reject: false,
|
reject: false,
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,19 @@
|
||||||
import he from 'he'
|
import he from 'he'
|
||||||
|
|
||||||
export function stripHtmlToText(html: string): string {
|
export function stripHtmlToText(html: string): string {
|
||||||
|
// Iteratively strip script/style blocks until stable to prevent nested bypass
|
||||||
|
// (e.g. <<scr<script>ipt>...</script>)
|
||||||
|
let prev = ''
|
||||||
|
let content = html
|
||||||
|
const scriptStyleRegex = /<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi
|
||||||
|
while (content !== prev) {
|
||||||
|
prev = content
|
||||||
|
content = content.replace(scriptStyleRegex, '')
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
he
|
he
|
||||||
.decode(
|
.decode(
|
||||||
html
|
content
|
||||||
// Remove script/style blocks and their content
|
|
||||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
||||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
||||||
// Replace <br>, <p>, <li>, <tr> with newlines
|
// Replace <br>, <p>, <li>, <tr> with newlines
|
||||||
.replace(/<(br|p|li|tr)\b[^>]*\/?>/gi, '\n')
|
.replace(/<(br|p|li|tr)\b[^>]*\/?>/gi, '\n')
|
||||||
// Replace </p>, </li>, </tr>, </div>, </h[1-6]> with newlines
|
// Replace </p>, </li>, </tr>, </div>, </h[1-6]> with newlines
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { execa } from 'execa'
|
import { execa, execaSync } from 'execa'
|
||||||
import { execSync_DEPRECATED } from './execSyncWrapper.js'
|
|
||||||
|
|
||||||
async function whichNodeAsync(command: string): Promise<string | null> {
|
async function whichNodeAsync(command: string): Promise<string | null> {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
// On Windows, use where.exe and return the first result
|
// On Windows, use where.exe and return the first result
|
||||||
const result = await execa(`where.exe ${command}`, {
|
// Security: array args form prevents shell injection
|
||||||
shell: true,
|
const result = await execa('where.exe', [command], {
|
||||||
stderr: 'ignore',
|
stderr: 'ignore',
|
||||||
reject: false,
|
reject: false,
|
||||||
})
|
})
|
||||||
|
|
@ -19,8 +18,8 @@ async function whichNodeAsync(command: string): Promise<string | null> {
|
||||||
// On POSIX systems (macOS, Linux, WSL), use which
|
// On POSIX systems (macOS, Linux, WSL), use which
|
||||||
// Cross-platform safe: Windows is handled above
|
// Cross-platform safe: Windows is handled above
|
||||||
// eslint-disable-next-line custom-rules/no-cross-platform-process-issues
|
// eslint-disable-next-line custom-rules/no-cross-platform-process-issues
|
||||||
const result = await execa(`which ${command}`, {
|
// Security: array args form prevents shell injection
|
||||||
shell: true,
|
const result = await execa('which', [command], {
|
||||||
stderr: 'ignore',
|
stderr: 'ignore',
|
||||||
reject: false,
|
reject: false,
|
||||||
})
|
})
|
||||||
|
|
@ -33,11 +32,13 @@ async function whichNodeAsync(command: string): Promise<string | null> {
|
||||||
function whichNodeSync(command: string): string | null {
|
function whichNodeSync(command: string): string | null {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
try {
|
try {
|
||||||
const result = execSync_DEPRECATED(`where.exe ${command}`, {
|
// Security: use execaSync with array args to prevent shell injection
|
||||||
encoding: 'utf-8',
|
const result = execaSync('where.exe', [command], {
|
||||||
|
encoding: 'utf-8' as const,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
reject: false,
|
||||||
})
|
})
|
||||||
const output = result.toString().trim()
|
const output = (result.stdout ?? '').trim()
|
||||||
return output.split(/\r?\n/)[0] || null
|
return output.split(/\r?\n/)[0] || null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|
@ -45,11 +46,13 @@ function whichNodeSync(command: string): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = execSync_DEPRECATED(`which ${command}`, {
|
// Security: use execaSync with array args to prevent shell injection
|
||||||
encoding: 'utf-8',
|
const result = execaSync('which', [command], {
|
||||||
|
encoding: 'utf-8' as const,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
reject: false,
|
||||||
})
|
})
|
||||||
return result.toString().trim() || null
|
return (result.stdout ?? '').trim() || null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user