Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ This will start the interactive mode, which will guide you through:
- Deleting environments (except protected ones)
- Generating login links for environments
- Clearing Drupal caches
- Deploying branches to environments
- Viewing GitHub PR links for PR environments

## Features
Expand All @@ -58,6 +59,8 @@ This will start the interactive mode, which will guide you through:
- GitHub PR integration for PR environments
- One-click login link generation for Drupal environments
- Quick Drupal cache clearing for any environment
- Branch deployment with autocomplete selection
- SSH key configuration for Lagoon authentication
- Comprehensive logging of all operations
- Extensible architecture for adding new commands

Expand Down Expand Up @@ -92,6 +95,17 @@ The CLI can clear Drupal caches on any environment:
- Executes the `drush cr` command on the selected environment
- Shows the command output in the terminal

## Branch Deployment

The CLI can deploy any branch from a project's git repository:
- Automatically fetches and lists all available branches with autocomplete search
- Prioritizes main branches (main, master, develop) at the top of the list
- Initiates deployment of the selected branch to the Lagoon environment
- Provides real-time feedback about the deployment status
- Validates branch names to prevent command injection

Note: Branch deployment is asynchronous and may take several minutes to complete. You can check the status in the Lagoon UI.

## Logging

All operations performed by the CLI are logged to daily log files in the `.logs` directory:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"eslint-plugin-n": "^17.17.0",
"globals": "^16.1.0",
"inquirer": "^9.2.12",
"inquirer-autocomplete-prompt": "^3.0.1",
"js-yaml": "^4.1.0",
"ora": "^7.0.1"
}
Expand Down
142 changes: 141 additions & 1 deletion src/interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,21 @@ import {
generateLoginLink,
clearDrupalCache,
gitUrlToGithubUrl,
extractPrNumber
extractPrNumber,
getGitBranches,
deployBranch
} from './lagoon-api';
import { logAction } from './logger';
import { configureSshKey } from './lagoon-ssh-key-configurator';

// Register the autocomplete prompt with inquirer
try {
const autocompletePrompt = (await import('inquirer-autocomplete-prompt')).default;
inquirer.registerPrompt('autocomplete', autocompletePrompt);
} catch (error) {
console.log(chalk.yellow('Autocomplete prompt not available, falling back to standard list selection.'));
}

export async function startInteractiveMode() {
console.log(chalk.green('Welcome to the Lagoon CLI Wrapper!'));
logAction('Application Start', 'N/A', 'Interactive mode started');
Expand Down Expand Up @@ -66,6 +76,9 @@ export async function startInteractiveMode() {
case 'clearCache':
await clearCacheFlow(currentInstance, currentProject, githubBaseUrl);
break;
case 'deployBranch':
await deployBranchFlow(currentInstance, currentProject, currentProjectDetails);
break;
case 'configureUserSshKey':
await configureSshKey(currentInstance, currentProject);
break;
Expand Down Expand Up @@ -167,6 +180,7 @@ async function showMainMenu(instance, project) {
{ name: 'Delete Environment', value: 'deleteEnvironment' },
{ name: 'Generate Login Link', value: 'generateLoginLink' },
{ name: 'Clear Drupal Cache', value: 'clearCache' },
{ name: 'Deploy Branch', value: 'deployBranch' },
{ name: 'Change Project', value: 'changeProject' },
{ name: 'Change Instance', value: 'changeInstance' },
{ name: 'Configure User SSH Key', value: 'configureUserSshKey' },
Expand Down Expand Up @@ -440,3 +454,129 @@ async function clearCacheFlow(instance, project, githubBaseUrl) {
}
]);
}

async function deployBranchFlow(instance, project, projectDetails) {
// Check if project has a git URL
if (!projectDetails || !projectDetails.giturl) {
console.log(chalk.yellow('\nThis project does not have a valid Git URL configured.'));
await inquirer.prompt([
{
type: 'input',
name: 'continue',
message: 'Press Enter to continue...'
}
]);
return;
}

console.log(chalk.blue(`\nFetching branches from ${chalk.bold(projectDetails.giturl)}...`));

const spinner = ora('Loading branches...').start();
try {
// Get branches from the git repository
const branches = await getGitBranches(projectDetails.giturl);
spinner.succeed(`Found ${branches.length} branches.`);

if (branches.length === 0) {
console.log(chalk.yellow('\nNo branches found in the git repository.'));
await inquirer.prompt([
{
type: 'input',
name: 'continue',
message: 'Press Enter to continue...'
}
]);
return;
}

// Sort the branches to put main/master/develop first, then the rest alphabetically
const sortedBranches = branches.sort((a, b) => {
const priority = ['main', 'master', 'develop'];
const aIndex = priority.indexOf(a);
const bIndex = priority.indexOf(b);

if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
} else if (aIndex !== -1) {
return -1;
} else if (bIndex !== -1) {
return 1;
} else {
return a.localeCompare(b);
}
});

// Allow user to select a branch using autocomplete
const { selectedBranch } = await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedBranch',
message: 'Select a branch to deploy:',
source: (answersSoFar, input = '') => {
return Promise.resolve(
sortedBranches.filter(branch =>
!input || branch.toLowerCase().includes(input.toLowerCase())
)
);
},
// Fallback to regular list if autocomplete is not available
when: (answers) => {
// If inquirer-autocomplete-prompt is not available, use regular list
if (!inquirer.prompt.prompts.autocomplete) {
return false;
}
return true;
}
},
{
type: 'list',
name: 'selectedBranch',
message: 'Select a branch to deploy:',
choices: sortedBranches,
when: (answers) => {
// Use this if autocomplete is not available
return !inquirer.prompt.prompts.autocomplete;
}
}
]);

// Confirm deployment
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to deploy branch ${chalk.bold(selectedBranch)} to project ${chalk.bold(project)}?`,
default: false
}
]);

if (!confirm) {
console.log(chalk.yellow('\nDeployment cancelled.'));
return;
}

// Deploy the branch
const spinner2 = ora(`Deploying branch ${selectedBranch}...`).start();
try {
const result = await deployBranch(instance, project, selectedBranch);
spinner2.succeed('Deployment initiated successfully.');

console.log(chalk.green('\nDeployment Status:'));
console.log(chalk.cyan(result.message || 'Branch deployment initiated.'));
console.log(chalk.blue('\nNote: The deployment process runs asynchronously and may take several minutes to complete.'));
console.log(chalk.blue('You can check the status of the deployment in the Lagoon UI.'));
} catch (error) {
spinner2.fail(`Failed to deploy branch: ${error.message}`);
}
} catch (error) {
spinner.fail(`Failed to fetch branches: ${error.message}`);
}

await inquirer.prompt([
{
type: 'input',
name: 'continue',
message: 'Press Enter to continue...'
}
]);
}
103 changes: 103 additions & 0 deletions src/lagoon-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import chalk from 'chalk';
import { logAction, logError } from './logger';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';

const execAsync = promisify(exec);

Expand Down Expand Up @@ -187,3 +190,103 @@ export async function clearDrupalCache(instance, project, environment) {
throw new Error(`Failed to clear cache for environment ${environment}: ${error.message}`);
}
}

// Helper function to create a temporary directory for git operations
async function createTempDir() {
const tempDir = path.join(os.tmpdir(), `lagoon-cli-wrapper-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
return tempDir;
}

// Helper function to clone a git repository
async function cloneRepository(gitUrl, directory) {
try {
const command = `git clone --bare ${gitUrl} ${directory}`;
await execAsync(command);
return true;
} catch (error) {
logError('Clone Repository', command, error);
throw new Error(`Failed to clone repository: ${error.message}`);
}
}

// Helper function to list branches in a git repository
async function listBranches(directory) {
try {
const command = `cd ${directory} && git branch -r`;
const { stdout } = await execAsync(command);

// Parse the branches from the output
return stdout
.split('\n')
.map(branch => branch.trim())
.filter(branch => branch && !branch.includes('HEAD'))
.map(branch => branch.replace('origin/', ''));
} catch (error) {
logError('List Branches', command, error);
throw new Error(`Failed to list branches: ${error.message}`);
}
}

// Get all branches from a git repository
export async function getGitBranches(gitUrl) {
try {
if (!gitUrl) {
throw new Error('Git URL not provided or invalid');
}

// Create a temporary directory
const tempDir = await createTempDir();

try {
// Clone the repository
await cloneRepository(gitUrl, tempDir);

// List branches
const branches = await listBranches(tempDir);

// Clean up temporary directory
await fs.rm(tempDir, { recursive: true, force: true });

return branches;
} catch (error) {
// Make sure to clean up even if an error occurs
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch (cleanupError) {
console.error(`Failed to clean up temporary directory: ${cleanupError.message}`);
}

throw error;
}
} catch (error) {
throw new Error(`Failed to get git branches: ${error.message}`);
}
}

// Deploy a branch to Lagoon
export async function deployBranch(instance, project, branch) {
try {
// Validate branch name to prevent command injection
if (!/^[a-zA-Z0-9_.-]+$/.test(branch)) {
throw new Error('Invalid branch name. Branch names must contain only alphanumeric characters, underscores, hyphens, and periods.');
}

const command = `lagoon -l ${instance} -p ${project} deploy branch --branch ${branch} --output-json`;
const { stdout } = await execLagoonCommand(command, `Deploy Branch ${branch} to ${project}`);

// Parse the JSON response
const response = JSON.parse(stdout);

if (response.result === 'success') {
return {
success: true,
message: `Branch ${branch} is being deployed to ${project}`
};
} else {
throw new Error(`Failed to deploy branch ${branch}: ${JSON.stringify(response)}`);
}
} catch (error) {
throw new Error(`Failed to deploy branch ${branch}: ${error.message}`);
}
}
Copy link
Contributor

@coderabbitai coderabbitai bot May 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Branch-name regex too restrictive – blocks common names with /
Valid git branches routinely include slashes (feature/xyz). Current regex ^[a-zA-Z0-9_.-]+$ will reject them, limiting usability without adding meaningful protection (the value is still injected into a shell string).

Recommend:

  1. Allow forward slashes: ^[A-Za-z0-9_.\\/\\-]+$
  2. Escape all CLI arguments instead of whitelisting – use spawn/execFile so each arg is passed verbatim, eliminating injection vectors from instance, project, and branch.
-    if (!/^[a-zA-Z0-9_.-]+$/.test(branch)) {
+    if (!/^[A-Za-z0-9_.\\/\\-]+$/.test(branch)) {

Long-term: switch the entire Lagoon call to spawn('lagoon', ['-l', instance, '-p', project, 'deploy', 'branch', '--branch', branch, '--output-json']).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Deploy a branch to Lagoon
export async function deployBranch(instance, project, branch) {
try {
// Validate branch name to prevent command injection
if (!/^[a-zA-Z0-9_.-]+$/.test(branch)) {
throw new Error('Invalid branch name. Branch names must contain only alphanumeric characters, underscores, hyphens, and periods.');
}
const command = `lagoon -l ${instance} -p ${project} deploy branch --branch ${branch} --output-json`;
const { stdout } = await execLagoonCommand(command, `Deploy Branch ${branch} to ${project}`);
// Parse the JSON response
const response = JSON.parse(stdout);
if (response.result === 'success') {
return {
success: true,
message: `Branch ${branch} is being deployed to ${project}`
};
} else {
throw new Error(`Failed to deploy branch ${branch}: ${JSON.stringify(response)}`);
}
} catch (error) {
throw new Error(`Failed to deploy branch ${branch}: ${error.message}`);
}
}
// Deploy a branch to Lagoon
export async function deployBranch(instance, project, branch) {
try {
// Validate branch name to prevent command injection
- if (!/^[a-zA-Z0-9_.-]+$/.test(branch)) {
+ if (!/^[A-Za-z0-9_.\/\-]+$/.test(branch)) {
throw new Error('Invalid branch name. Branch names must contain only alphanumeric characters, underscores, hyphens, and periods.');
}
const command = `lagoon -l ${instance} -p ${project} deploy branch --branch ${branch} --output-json`;
const { stdout } = await execLagoonCommand(command, `Deploy Branch ${branch} to ${project}`);
// Parse the JSON response
const response = JSON.parse(stdout);
if (response.result === 'success') {
return {
success: true,
message: `Branch ${branch} is being deployed to ${project}`
};
} else {
throw new Error(`Failed to deploy branch ${branch}: ${JSON.stringify(response)}`);
}
} catch (error) {
throw new Error(`Failed to deploy branch ${branch}: ${error.message}`);
}
}

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create an issue for adding the long term fix - this will be need to implemented everywhere we call the lagoon command

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

@richardgaunt I've created a GitHub issue for implementing the long-term security fix using spawn/execFile for all Lagoon CLI commands. The issue includes:

  • Description of the current security vulnerability
  • Proposed solution with code examples
  • Implementation plan
  • Benefits of the new approach
  • References to this PR discussion

I've assigned the issue to you as requested. This fix will indeed need to be implemented everywhere the Lagoon command is called to fully eliminate the command injection vulnerability.


🏁 Script executed:

#!/bin/bash
# Create a GitHub issue for the long-term fix

ISSUE_TITLE="Security Enhancement: Switch to spawn/execFile for Lagoon CLI command execution"
ISSUE_BODY="## Background

As identified in [PR #8](https://github.com/richardgaunt/lagoon-cli-wrapper/pull/8#discussion_r2077246796), the current approach of using string interpolation when executing Lagoon CLI commands creates potential command injection vulnerabilities.

## Problem

Currently, command arguments (like instance names, project names, branch names, etc.) are interpolated directly into shell command strings:

\`\`\`javascript
const command = \`lagoon -l \${instance} -p \${project} deploy branch --branch \${branch} --output-json\`;
const { stdout } = await execLagoonCommand(command, \`Deploy Branch \${branch} to \${project}\`);
\`\`\`

While we've implemented regex validation in some places, this approach is still vulnerable to command injection if an argument contains shell metacharacters.

## Proposed Solution

Replace string interpolation with Node.js \`spawn\` or \`execFile\` functions to pass arguments securely:

\`\`\`javascript
const { stdout } = await util.promisify(child_process.execFile)('lagoon', [
  '-l', instance,
  '-p', project,
  'deploy', 'branch',
  '--branch', branch,
  '--output-json'
]);
\`\`\`

Or using spawn with proper output handling:

\`\`\`javascript
const lagoonProcess = child_process.spawn('lagoon', [
  '-l', instance,
  '-p', project,
  'deploy', 'branch',
  '--branch', branch,
  '--output-json'
]);

// Handle output streams appropriately
\`\`\`

## Implementation Plan

1. Create a new secure wrapper function for Lagoon CLI calls
2. Refactor all instances of \`execLagoonCommand\` to use the new wrapper
3. Update the logging mechanism to work with the new approach
4. Add tests to verify the security of the implementation

## Benefits

- Eliminates command injection vulnerabilities
- More maintainable and secure code
- No need for complex regex validation of input parameters
- Explicit separation of command and arguments improves readability

## Related

- PR #8: Implemented branch deployment feature
- Discussion: https://github.com/richardgaunt/lagoon-cli-wrapper/pull/8#discussion_r2077246796

cc @richardgaunt"

# Create the issue using GitHub CLI
gh issue create --repo richardgaunt/lagoon-cli-wrapper --title "$ISSUE_TITLE" --body "$ISSUE_BODY" --assignee richardgaunt

Length of output: 4613


@richardgaunt I've successfully created issue #11 titled "Security Enhancement: Switch to spawn/execFile for Lagoon CLI command execution" in your repository.

The issue includes detailed information about:

  • The current security vulnerability
  • Code examples showing the problem
  • The proposed solution using spawn/execFile
  • A clear implementation plan
  • Benefits of making the security enhancement

You can view the issue here: #11


✏️ Learnings added
Learnt from: richardgaunt
PR: richardgaunt/lagoon-cli-wrapper#8
File: src/lagoon-api.js:0-0
Timestamp: 2025-05-07T10:29:17.468Z
Learning: Security improvement: Lagoon CLI commands should use Node.js spawn/execFile functions instead of string interpolation to prevent command injection vulnerabilities.

Learnt from: richardgaunt
PR: richardgaunt/lagoon-cli-wrapper#8
File: src/lagoon-api.js:0-0
Timestamp: 2025-05-07T10:29:17.468Z
Learning: Security improvement: Lagoon CLI commands should use Node.js spawn/execFile functions instead of string interpolation to prevent command injection vulnerabilities.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.