Conversation
…tion or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This pull request enhances security and code quality by refactoring command execution from execSync with shell strings to spawnSync with argument arrays, preventing potential shell injection vulnerabilities. The changes also add explicit GitHub workflow permissions following the principle of least privilege.
Changes:
- Replaced
execSyncwithspawnSyncfor safer command execution without shell interpretation - Converted command building from string concatenation to array-based arguments
- Updated test suite to mock
spawnSyncandfs.existsSyncinstead ofexecSync
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/shared/utils/ddev-utils.ts | Refactored command execution to use spawnSync with array arguments and improved error handling |
| src/shared/services/base-php-tool-service.ts | Updated buildToolCommand signature to return string array instead of string |
| src/services/phpstan-service.ts | Converted command building from string concatenation to array construction |
| src/test/ddev-utils.test.ts | Updated tests to mock spawnSync and fs.existsSync with comprehensive test cases |
| .github/workflows/dependabot.yml | Added explicit permissions for pull requests and contents |
| .github/workflows/code-quality.yml | Added explicit read-only permissions for contents |
| .github/workflows/ci.yml | Added explicit read-only permissions for contents |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/shared/utils/ddev-utils.ts
Outdated
| throw enhancedError; | ||
| } | ||
|
|
||
| return result.stdout; |
There was a problem hiding this comment.
This line is unreachable code. If result.status === 0 (which is required to reach this line), and allowedExitCodes contains 0 (which it does by default), the function would have already returned at line 203. This line can only theoretically be reached if someone passes custom allowedExitCodes that doesn't include 0, which would be an unusual edge case. Consider removing this line or restructuring the logic for clarity.
src/test/ddev-utils.test.ts
Outdated
| import * as sinon from 'sinon'; | ||
| import { afterEach, beforeEach } from 'mocha'; | ||
| import { DdevUtils } from '../shared/utils/ddev-utils'; | ||
| import * as fs from 'fs'; |
There was a problem hiding this comment.
These import statements are unused. The code uses require at lines 35-36 to import the same modules, which shadows these imports. Consider removing these unused import statements or using them directly instead of calling require.
| import * as fs from 'fs'; |
src/shared/utils/ddev-utils.ts
Outdated
| } | ||
|
|
||
| if (result.status !== 0) { | ||
| // Enhance error message with more details |
There was a problem hiding this comment.
Inconsistent indentation: this comment line has an extra leading space. The comment should align with the code at the same indentation level.
src/shared/utils/ddev-utils.ts
Outdated
|
|
||
| return result.stdout; | ||
| } catch (error: any) { | ||
| // If error was already thrown above, rethrow it |
There was a problem hiding this comment.
Inconsistent indentation: this comment line has an extra leading space. The comment should align with the code at the same indentation level.
…tion or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
|
@copilot open a new pull request to apply changes based on the comments in this thread |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| cwd: workspacePath, | ||
| stdio: 'ignore' | ||
| encoding: 'utf-8' | ||
| }); |
There was a problem hiding this comment.
The isDdevRunning method doesn't handle the case where result.status is null, which can occur when the spawn operation fails (e.g., if 'ddev' command is not found). This will incorrectly return false when it should be distinguishing between "ddev not running" and "ddev command not found". Consider explicitly checking for result.status === null or adding a check for result.error before the status check.
| }); | |
| }); | |
| if (result.error) { | |
| vscode.window.showErrorMessage( | |
| 'Failed to execute "ddev". Please ensure DDEV is installed and available in your PATH.', | |
| ); | |
| return false; | |
| } | |
| if (result.status === null) { | |
| vscode.window.showErrorMessage( | |
| 'The "ddev" command did not exit normally. Please check your DDEV installation.', | |
| ); | |
| return false; | |
| } |
src/services/phpstan-service.ts
Outdated
| if (this.config.excludePaths && this.config.excludePaths.length > 0) { | ||
| for (const excludePath of this.config.excludePaths) { | ||
| command += ` --exclude="${excludePath}"`; | ||
| command.push(`--exclude=${excludePath}`); |
There was a problem hiding this comment.
The excludePath values should be properly quoted or escaped when pushed to the command array. The current implementation uses command.push(\--exclude=${excludePath}`)which concatenates the path into the option value. If excludePath contains special characters or spaces, this could cause issues. Consider using separate arguments:command.push('--exclude', excludePath)` to let the shell handle quoting properly.
| command.push(`--exclude=${excludePath}`); | |
| command.push('--exclude', excludePath); |
src/services/phpstan-service.ts
Outdated
| } else { | ||
| // Use individual settings when no config file is found | ||
| command += ` --level=${this.config.level}`; | ||
| command.push(`--level=${this.config.level}`); |
There was a problem hiding this comment.
Similar to the exclude path issue, the level value is concatenated into the option using command.push(\--level=${this.config.level}`). While this is likely safe for numeric level values, for consistency and safety with spawnSync's array-based approach, consider using separate arguments: command.push('--level', String(this.config.level))`.
| command.push(`--level=${this.config.level}`); | |
| command.push('--level', String(this.config.level)); |
…nd clean up imports Co-authored-by: Morgy93 <7961978+Morgy93@users.noreply.github.com>
Address code review findings: remove unreachable code and fix style issues
|
@copilot open a new pull request to apply changes based on the comments in this thread |
…n options Co-authored-by: Morgy93 <7961978+Morgy93@users.noreply.github.com>
Handle null status in isDdevRunning and use separate args for PHPStan options
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/test/ddev-utils.test.ts
Outdated
| // Try to stub, but handle if it fails (basic check) | ||
| try { | ||
| spawnSyncStub = sandbox.stub(cp, 'spawnSync'); | ||
| existsSyncStub = sandbox.stub(fs, 'existsSync'); | ||
| } catch (e) { | ||
| console.error('Failed to stub modules:', e); | ||
| throw e; | ||
| } |
There was a problem hiding this comment.
The try-catch block in the test setup (lines 36-42) is unnecessary and adds complexity. If stubbing fails, it's a test setup issue that should fail immediately. The explicit error logging and re-throwing doesn't add value since the test framework will handle the error appropriately.
| // Try to stub, but handle if it fails (basic check) | |
| try { | |
| spawnSyncStub = sandbox.stub(cp, 'spawnSync'); | |
| existsSyncStub = sandbox.stub(fs, 'existsSync'); | |
| } catch (e) { | |
| console.error('Failed to stub modules:', e); | |
| throw e; | |
| } | |
| spawnSyncStub = sandbox.stub(cp, 'spawnSync'); | |
| existsSyncStub = sandbox.stub(fs, 'existsSync'); |
| test('isDdevRunning returns true when DDEV container is running', () => { | ||
| execSyncStub.returns(''); | ||
| spawnSyncStub.returns({ status: 0, stdout: 'test\n' }); | ||
|
|
||
| const result = DdevUtils.isDdevRunning('/test/workspace'); | ||
|
|
||
| assert.strictEqual(result, true); | ||
| }); | ||
|
|
||
| test('isDdevRunning returns false when DDEV container is not running', () => { | ||
| execSyncStub.throws(new Error('Container not running')); | ||
| spawnSyncStub.returns({ status: 1, stderr: 'Container not running' }); | ||
|
|
||
| const result = DdevUtils.isDdevRunning('/test/workspace'); | ||
|
|
||
| assert.strictEqual(result, false); | ||
| }); |
There was a problem hiding this comment.
The tests for isDdevRunning don't cover the error handling paths where result.error is truthy (line 70-74 in implementation) or where result.status is null (line 76-80 in implementation). These error conditions should be tested to ensure the error messages are shown correctly and the method returns false as expected.
| test('execDdev passes args array correctly', () => { | ||
| spawnSyncStub.returns({ status: 0, stdout: 'output' }); | ||
|
|
||
| const result = DdevUtils.execDdev('phpstan analyze', '/test/workspace'); | ||
| const result = DdevUtils.execDdev(['phpstan', 'analyze'], '/test/workspace'); | ||
|
|
||
| assert.strictEqual(result, 'output'); | ||
| assert.strictEqual(execSyncStub.calledOnce, true); | ||
| const callArgs = execSyncStub.firstCall.args; | ||
| assert.ok(callArgs[0].includes("XDEBUG_MODE=off")); | ||
| assert.ok(callArgs[0].includes("bash -c")); | ||
| assert.ok(callArgs[0].includes("'XDEBUG_MODE=off phpstan analyze'")); | ||
| assert.strictEqual(spawnSyncStub.calledOnce, true); | ||
| const callArgs = spawnSyncStub.firstCall.args; | ||
| assert.strictEqual(callArgs[0], 'ddev'); | ||
| assert.deepStrictEqual(callArgs[1], ['exec', 'env', 'XDEBUG_MODE=off', 'phpstan', 'analyze']); | ||
| assert.strictEqual(callArgs[2].cwd, '/test/workspace'); | ||
| }); | ||
|
|
||
| test('execDdev escapes single quotes in command', () => { | ||
| execSyncStub.returns('output'); | ||
| test('execDdev throws on non-zero exit code', () => { | ||
| spawnSyncStub.returns({ status: 1, stderr: 'error', stdout: '' }); | ||
|
|
||
| const result = DdevUtils.execDdev("echo 'hello'", '/test/workspace'); | ||
| assert.throws(() => { | ||
| DdevUtils.execDdev(['ls'], '/test/workspace'); | ||
| }, (err: any) => { | ||
| return err.status === 1 && err.stderr === 'error'; | ||
| }); | ||
| }); | ||
|
|
||
| assert.strictEqual(result, 'output'); | ||
| const callArgs = execSyncStub.firstCall.args; | ||
| // Should be: ddev exec bash -c 'XDEBUG_MODE=off echo '\''hello'\''' | ||
| assert.ok(callArgs[0].includes("'XDEBUG_MODE=off echo '\\''hello'\\'''")); | ||
| test('execDdev returns stdout on allowed non-zero exit code', () => { | ||
| spawnSyncStub.returns({ status: 1, stdout: 'partial output' }); | ||
|
|
||
| const result = DdevUtils.execDdev(['ls'], '/test/workspace', [0, 1]); | ||
|
|
||
| assert.strictEqual(result, 'partial output'); | ||
| }); |
There was a problem hiding this comment.
Test coverage is missing for execDdev error handling when result.error is present (line 205-206 in implementation) and when result.status is null (which would cause the function to exit without returning). These edge cases should be tested to ensure proper error handling.
| // Check if this is an acceptable exit code | ||
| if (result.status !== null && allowedExitCodes.includes(result.status)) { | ||
| // Return stdout even if exit code is non-zero but allowed | ||
| console.log(`Command exited with allowed code ${error.status}: ${command}`); | ||
| return error.stdout || ''; | ||
| if (result.status !== 0) { | ||
| console.log(`Command exited with allowed code ${result.status}: ${command.join(' ')}`); | ||
| } | ||
| return result.stdout || ''; | ||
| } | ||
|
|
||
| if (result.status !== 0) { | ||
| // Enhance error message with more details | ||
| const enhancedError = new Error(result.stderr || 'Command execution failed'); | ||
| enhancedError.name = 'CommandError'; | ||
| (enhancedError as any).status = result.status; | ||
| (enhancedError as any).stderr = result.stderr; | ||
| (enhancedError as any).stdout = result.stdout; | ||
| (enhancedError as any).command = `ddev exec ${command.join(' ')}`; | ||
| (enhancedError as any).workspacePath = workspacePath; | ||
| throw enhancedError; | ||
| } |
There was a problem hiding this comment.
The execDdev method has a logical gap in its exit code handling. If result.status === 0 but 0 is not in the allowedExitCodes array (line 210 condition fails), and the check at line 218 (result.status !== 0) also fails, the function exits the try block without returning a value. While this is unlikely with the default allowedExitCodes = [0], it could occur if a caller explicitly sets allowedExitCodes to exclude 0 (e.g., [1, 2]). The function should either throw an error or return stdout in this case.
| vscode.window.showErrorMessage( | ||
| 'Failed to execute "ddev". Please ensure DDEV is installed and available in your PATH.', | ||
| ); | ||
| return false; | ||
| } | ||
| if (result.status === null) { | ||
| vscode.window.showErrorMessage( | ||
| 'The "ddev" command did not exit normally. Please check your DDEV installation.', | ||
| ); | ||
| return false; | ||
| } | ||
| return result.status === 0; | ||
| } catch (error) { |
There was a problem hiding this comment.
The isDdevRunning method shows error messages to the user via vscode.window.showErrorMessage even though it's meant to be a simple boolean check. This creates unexpected UI popups during validation flows. The error messages should be logged instead, or the method should return more detailed information that callers can use to show appropriate messages.
| vscode.window.showErrorMessage( | |
| 'Failed to execute "ddev". Please ensure DDEV is installed and available in your PATH.', | |
| ); | |
| return false; | |
| } | |
| if (result.status === null) { | |
| vscode.window.showErrorMessage( | |
| 'The "ddev" command did not exit normally. Please check your DDEV installation.', | |
| ); | |
| return false; | |
| } | |
| return result.status === 0; | |
| } catch (error) { | |
| console.error( | |
| 'Failed to execute "ddev". Please ensure DDEV is installed and available in your PATH.', | |
| result.error | |
| ); | |
| return false; | |
| } | |
| if (result.status === null) { | |
| console.error( | |
| 'The "ddev" command did not exit normally. Please check your DDEV installation.', | |
| result.stderr | |
| ); | |
| return false; | |
| } | |
| return result.status === 0; | |
| } catch (error) { | |
| console.error('Unexpected error while checking DDEV status.', error); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| (enhancedError as any).command = `ddev exec ${command.join(' ')}`; | ||
| (enhancedError as any).workspacePath = workspacePath; | ||
| throw enhancedError; | ||
| } |
There was a problem hiding this comment.
The function has a logic gap: if result.status is 0 but 0 is not in allowedExitCodes, the function will not return or throw, resulting in an implicit undefined return. After line 223 returns for allowed exit codes, add an explicit return statement for the case where result.status === 0, or restructure the logic to ensure all code paths return a value or throw an error. Consider checking if result.status === 0 first and returning the stdout, then checking allowedExitCodes for non-zero statuses.
| } | |
| } | |
| // If we reach this point, the command exited successfully with status 0. | |
| // Return stdout to avoid an implicit undefined return value. | |
| return result.stdout || ''; |
| (enhancedError as any).status = result.status; | ||
| (enhancedError as any).stderr = result.stderr; | ||
| (enhancedError as any).stdout = result.stdout; | ||
| (enhancedError as any).command = `ddev exec ${command.join(' ')}`; |
There was a problem hiding this comment.
The reconstructed command string does not include the 'env' and 'XDEBUG_MODE=off' arguments that are actually part of the executed command (see line 206). For better debugging and error messages, consider including these in the command string, such as: ddev exec env XDEBUG_MODE=off ${command.join(' ')}
This pull request refactors how commands are executed within the DDEV environment by replacing usage of
execSyncwithspawnSyncand by consistently passing command arguments as arrays instead of strings. This improves security, reliability, and testability. The changes also update related tests and command-building logic to match the new approach, and adjust GitHub workflow permissions for better security.DDEV command execution and utility improvements:
DdevUtils.execDdevto usespawnSyncand accept command arguments as arrays, improving security and preventing shell injection. Enhanced error handling and environment variable management for tool execution. (src/shared/utils/ddev-utils.ts) [1] [2]hasDdevProject,isDdevRunning,isToolInstalled,validateDdevTool) to use the new argument array approach andspawnSync. (src/shared/utils/ddev-utils.ts) [1] [2] [3] [4]PHPStan service and base service changes:
BasePhpToolServiceandPhpstanServiceto build tool commands as string arrays instead of strings, aligning with the new execution model. (src/shared/services/base-php-tool-service.ts,src/services/phpstan-service.ts) [1] [2]src/shared/services/base-php-tool-service.ts)Test suite modernization:
ddev-utils.test.tsto mockspawnSyncandfs.existsSyncinstead ofexecSync, and updated all relevant test cases to use the new command format and improved assertions. (src/test/ddev-utils.test.ts) [1] [2] [3] [4] [5]GitHub workflow security:
permissionsblocks to workflow YAML files to restrict token permissions, improving CI/CD security. (.github/workflows/ci.yml,.github/workflows/code-quality.yml,.github/workflows/dependabot.yml) [1] [2] [3]