Skip to content

Commit 22dc4a0

Browse files
Merge pull request #186 from codacy/escape-characters
fix: escape characters from paths CF-2117
2 parents b5c4528 + a8b968e commit 22dc4a0

9 files changed

Lines changed: 697 additions & 71 deletions

File tree

src/cli/CodacyCli.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ export const CODACY_FOLDER_NAME = '.codacy'
33
import { exec } from 'child_process'
44
import { Config } from '../common'
55
import Logger from '../common/logger'
6-
import { ProcessedSarifResult } from '.'
6+
import { ProcessedSarifResult } from './utils'
7+
import * as path from 'path'
78

89
// Set a larger buffer size (10MB)
910
const MAX_BUFFER_SIZE = 1024 * 1024 * 10
@@ -47,8 +48,80 @@ export abstract class CodacyCli {
4748
vscode.commands.executeCommand('setContext', 'codacy:cliInstalled', !!command)
4849
}
4950

51+
/**
52+
* Validates a file path for security concerns
53+
*
54+
* Security measures:
55+
* 1. Rejects null bytes (\0) - always indicates malicious intent
56+
* 2. Rejects control characters (ASCII 0-31 and 127)
57+
* 3. Prevents path traversal attacks by ensuring paths resolve within workspace
58+
*
59+
* Allows:
60+
* - Unicode characters (e.g., emoji, non-English characters)
61+
* - Common special characters in file names (spaces, parentheses, brackets, etc.)
62+
* - Shell metacharacters (will be escaped by preparePathForExec)
63+
*
64+
* @param filePath The path to validate
65+
* @returns true if the path is safe, false otherwise
66+
*/
67+
protected isPathSafe(filePath: string): boolean {
68+
// Reject null bytes (always a security risk)
69+
if (filePath.includes('\0')) {
70+
Logger.warn(`Path contains null byte: ${filePath}`)
71+
return false
72+
}
73+
74+
// Reject all control characters (including newline, tab, carriage return)
75+
// as they are very unusual for file names
76+
// eslint-disable-next-line no-control-regex -- Intentionally checking for control chars to reject them for security
77+
const hasUnsafeControlChars = /[\x00-\x1F\x7F]/.test(filePath)
78+
if (hasUnsafeControlChars) {
79+
Logger.warn(`Path contains unsafe control characters: ${filePath}`)
80+
return false
81+
}
82+
83+
// Resolve the path to check for path traversal attempts
84+
const resolvedPath = path.resolve(this.rootPath, filePath)
85+
const normalizedRoot = path.normalize(this.rootPath)
86+
// Check if the resolved path is within the workspace
87+
if (!resolvedPath.startsWith(normalizedRoot)) {
88+
Logger.warn(`Path traversal attempt detected: ${filePath} resolves outside workspace`)
89+
return false
90+
}
91+
92+
return true
93+
}
94+
95+
/**
96+
* Prepares a file path for safe shell execution by escaping special characters
97+
*
98+
* Security approach:
99+
* 1. First validates path using isPathSafe() (path traversal, null bytes, control chars)
100+
* 2. Then escapes all shell metacharacters to prevent command injection
101+
*
102+
* Escaped characters include:
103+
* - Command separators: ; & |
104+
* - Command substitution: ` $ ( )
105+
* - I/O redirection: < >
106+
* - Glob patterns: * ? [ ] { }
107+
* - Quotes and spaces: ' " \s
108+
* - Special shell chars: ~ \
109+
*
110+
* This allows legitimate file names with unusual but safe characters while
111+
* preventing command injection attacks.
112+
*
113+
* @param path The file path to prepare for shell execution
114+
* @returns Escaped path safe for shell execution
115+
* @throws Error if path fails security validation
116+
*/
50117
protected preparePathForExec(path: string): string {
51-
return path
118+
// Validate path security before escaping
119+
if (!this.isPathSafe(path)) {
120+
throw new Error(`Unsafe file path rejected: ${path}`)
121+
}
122+
123+
// Escape special characters for shell execution
124+
return path.replace(/([\s'"\\;&|`$()[\]{}*?~<>])/g, '\\$1')
52125
}
53126

54127
protected getIdentificationParameters(): Record<string, string> {
@@ -65,15 +138,24 @@ export abstract class CodacyCli {
65138
}
66139

67140
protected execAsync(command: string, args?: Record<string, string>): Promise<{ stdout: string; stderr: string }> {
68-
// stringyfy the args
141+
// Validate and escape arguments
69142
const argsString = Object.entries(args || {})
70-
.map(([key, value]) => `--${key} ${value}`)
143+
.map(([key, value]) => {
144+
// Validate argument key (should be alphanumeric and hyphens only)
145+
if (!/^[a-zA-Z0-9-]+$/.test(key)) {
146+
throw new Error(`Invalid argument key: ${key}`)
147+
}
148+
149+
// Escape the value to prevent injection
150+
const escapedValue = value.replace(/([\s'"\\;&|`$()[\]{}*?~<>])/g, '\\$1')
151+
return `--${key} ${escapedValue}`
152+
})
71153
.join(' ')
72154

73-
// Add the args to the command and remove any shell metacharacters
74-
const cmd = `${command} ${argsString}`.trim().replace(/[;&|`$]/g, '')
155+
// Build the command - no need to strip characters since we've already escaped them properly
156+
const cmd = `${command} ${argsString}`.trim()
75157

76-
// Add the CODACY_CLI_VERSION to the command
158+
// Execute command
77159
return new Promise((resolve, reject) => {
78160
exec(
79161
cmd,

src/cli/MacCodacyCli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { CodacyError } from '../common/utils'
55

66
import { CODACY_FOLDER_NAME, CodacyCli } from './CodacyCli'
77
import Logger from '../common/logger'
8-
import { ProcessedSarifResult, processSarifResults } from '.'
8+
import { ProcessedSarifResult, processSarifResults } from './utils'
99
import { Config } from '../common/config'
1010

1111
export class MacCodacyCli extends CodacyCli {

src/cli/WinCodacyCli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ProcessedSarifResult } from '.'
1+
import { ProcessedSarifResult } from './utils'
22
import { CodacyCli } from './CodacyCli'
33
import * as vscode from 'vscode'
44

src/cli/WinWSLCodacyCli.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { MacCodacyCli } from './MacCodacyCli'
22

3-
43
export class WinWSLCodacyCli extends MacCodacyCli {
54
constructor(rootPath: string, provider?: string, organization?: string, repository?: string) {
65
const winRootPath =
@@ -11,33 +10,48 @@ export class WinWSLCodacyCli extends MacCodacyCli {
1110
private static toWSLPath(path: string): string {
1211
// Convert Windows path to WSL path
1312
// Example: 'C:\Users\user\project' -> '/mnt/c/Users/user/project'
14-
// First, unescape any escaped spaces (backslash-space -> space)
15-
let cleanPath = path.replace(/^'|'$/g, '')
16-
// Unescape any escaped spaces (backslash-space -> space)
17-
cleanPath = cleanPath.replace(/\\ /g, ' ')
18-
// Then convert backslashes to slashes and add /mnt/ prefix
19-
const wslPath = cleanPath.replace(/\\/g, '/').replace(/^'?([a-zA-Z]):/, '/mnt/$1').replace(/ /g, '\\ ')
13+
// First, remove outer quotes if present
14+
const cleanPath = path.replace(/^["']|["']$/g, '')
15+
// Convert backslashes to slashes and handle drive letter
16+
const wslPath = cleanPath
17+
.replace(/\\/g, '/')
18+
.replace(/^([a-zA-Z]):/, (match, letter) => `/mnt/${letter.toLowerCase()}`)
2019
return wslPath
2120
}
2221

2322
private static fromWSLPath(path: string): string {
2423
// Convert WSL path to Windows path while keeping quotes
2524
// Example: '/mnt/c/Users/user/project' -> 'C:\Users\user\project'
26-
const windowsPath = path.replace(/^'\/mnt\/([a-zA-Z])/, "'$1:").replace(/\//g, '\\')
25+
const windowsPath = path
26+
.replace(/^'\/mnt\/([a-zA-Z])/, (match, letter) => `'${letter.toUpperCase()}:`)
27+
.replace(/^\/mnt\/([a-zA-Z])/, (match, letter) => `${letter.toUpperCase()}:`)
28+
.replace(/\//g, '\\')
2729
return windowsPath
2830
}
2931

30-
protected preparePathForExec(path: string): string {
31-
// Convert the path to WSL format and wrap in quotes to handle spaces
32-
const wslPath = WinWSLCodacyCli.toWSLPath(path)
33-
return wslPath.includes(' ') ? `"${wslPath}"` : wslPath;
32+
/**
33+
* Override path preparation to handle WSL paths correctly
34+
* Validates in Windows format, then converts to WSL and escapes
35+
*/
36+
protected preparePathForExec(filePath: string): string {
37+
// Convert WSL path to Windows format for validation
38+
const winFilePath = filePath.startsWith('/mnt/') ? WinWSLCodacyCli.fromWSLPath(filePath) : filePath
39+
40+
// Validate path security before escaping
41+
if (!this.isPathSafe(winFilePath)) {
42+
throw new Error(`Unsafe file path rejected: ${winFilePath}`)
43+
}
44+
// Convert to WSL format and escape special characters
45+
const wslPath = WinWSLCodacyCli.toWSLPath(winFilePath)
46+
const escapedPath = wslPath.replace(/([\s'"\\;&|`$()[\]{}*?~<>])/g, '\\$1')
47+
return `'${escapedPath}'`
3448
}
3549

3650
protected async execAsync(
3751
command: string,
3852
args?: Record<string, string>
3953
): Promise<{ stdout: string; stderr: string }> {
40-
return await super.execAsync(`wsl ${command}`, args)
54+
return await super.execAsync(`wsl bash -c "${command}"`, args)
4155
}
4256

4357
public getCliCommand(): string {

src/cli/index.ts

Lines changed: 1 addition & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { MacCodacyCli } from './MacCodacyCli'
44
import { LinuxCodacyCli } from './LinuxCodacyCli'
55
import { WinWSLCodacyCli } from './WinWSLCodacyCli'
66
import { WinCodacyCli } from './WinCodacyCli'
7-
import { Run } from 'sarif'
87

98
import { exec } from 'child_process'
109

@@ -97,45 +96,4 @@ export class Cli {
9796
}
9897
}
9998

100-
export const processSarifResults = (runs: Run[]) => {
101-
return (
102-
runs.flatMap((run) => {
103-
const tool = run.tool.driver.name
104-
const rules = Object.fromEntries(run.tool.driver.rules?.map((rule) => [rule.id, rule]) || [])
105-
106-
return (
107-
run.results?.flatMap((result) => {
108-
const rule = result.ruleId ? rules[result.ruleId] : null
109-
const level = result.level || 'error'
110-
const message = result.message?.text || 'No message provided.'
111-
112-
return (
113-
result.locations?.map((location) => {
114-
const filePath = location.physicalLocation?.artifactLocation?.uri
115-
116-
return {
117-
tool,
118-
rule: rule
119-
? {
120-
id: rule.id,
121-
name: rule.name,
122-
helpUri: rule.helpUri,
123-
shortDescription: rule.shortDescription?.text,
124-
}
125-
: result.ruleId
126-
? { id: result.ruleId }
127-
: undefined,
128-
level,
129-
message,
130-
filePath,
131-
region: location.physicalLocation?.region,
132-
}
133-
}) || []
134-
)
135-
}) || []
136-
)
137-
}) || []
138-
)
139-
}
140-
141-
export type ProcessedSarifResult = ReturnType<typeof processSarifResults>[number]
99+
export { ProcessedSarifResult, processSarifResults } from './utils'

src/cli/utils.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Run } from 'sarif'
2+
3+
export const processSarifResults = (runs: Run[]) => {
4+
return (
5+
runs.flatMap((run) => {
6+
const tool = run.tool.driver.name
7+
const rules = Object.fromEntries(run.tool.driver.rules?.map((rule) => [rule.id, rule]) || [])
8+
9+
return (
10+
run.results?.flatMap((result) => {
11+
const rule = result.ruleId ? rules[result.ruleId] : null
12+
const level = result.level || 'error'
13+
const message = result.message?.text || 'No message provided.'
14+
15+
return (
16+
result.locations?.map((location) => {
17+
const filePath = location.physicalLocation?.artifactLocation?.uri
18+
19+
return {
20+
tool,
21+
rule: rule
22+
? {
23+
id: rule.id,
24+
name: rule.name,
25+
helpUri: rule.helpUri,
26+
shortDescription: rule.shortDescription?.text,
27+
}
28+
: result.ruleId
29+
? { id: result.ruleId }
30+
: undefined,
31+
level,
32+
message,
33+
filePath,
34+
region: location.physicalLocation?.region,
35+
}
36+
}) || []
37+
)
38+
}) || []
39+
)
40+
}) || []
41+
)
42+
}
43+
44+
export type ProcessedSarifResult = ReturnType<typeof processSarifResults>[number]

0 commit comments

Comments
 (0)