Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 5 additions & 1 deletion packages/mcp-provider-devops/src/getPipelineMP.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Connection } from "@salesforce/core";
import { validateSalesforceId } from "./shared/soqlUtils.js";

export interface DevopsPipelineRecordMP {
Id: string;
Expand All @@ -13,10 +14,13 @@ export interface DevopsPipelineRecordMP {
*/
export async function getPipelineMP(connection: Connection, projectId: string): Promise<DevopsPipelineRecordMP | null | any> {
try {
// Validate projectId to prevent SOQL injection
const validatedProjectId = validateSalesforceId(projectId, 'projectId');

const query = `
SELECT Id, Name, sf_devops__Activated__c, sf_devops__Project__c
FROM sf_devops__Pipeline__c
WHERE sf_devops__Project__c = '${projectId}'
WHERE sf_devops__Project__c = '${validatedProjectId}'
AND sf_devops__Activated__c = true
LIMIT 1
`;
Expand Down
6 changes: 5 additions & 1 deletion packages/mcp-provider-devops/src/getPipelineStagesMP.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Connection } from "@salesforce/core";
import { validateSalesforceId } from "./shared/soqlUtils.js";

export interface PipelineStageRecordMP {
Id: string;
Expand All @@ -17,6 +18,9 @@ export interface PipelineStageRecordMP {
*/
export async function fetchPipelineStagesMP(connection: Connection, pipelineId: string): Promise<PipelineStageRecordMP[] | any> {
try {
// Validate pipelineId to prevent SOQL injection
const validatedPipelineId = validateSalesforceId(pipelineId, 'pipelineId');

const query = `
SELECT
Id,
Expand All @@ -28,7 +32,7 @@ export async function fetchPipelineStagesMP(connection: Connection, pipelineId:
sf_devops__Next_Stage__c,
sf_devops__Next_Stage__r.Name
FROM sf_devops__Pipeline_Stage__c
WHERE sf_devops__Pipeline__c = '${pipelineId}'
WHERE sf_devops__Pipeline__c = '${validatedPipelineId}'
`;
const result: any = await connection.query(query);
const records: any[] = result?.records || [];
Expand Down
11 changes: 9 additions & 2 deletions packages/mcp-provider-devops/src/getWorkItems.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Connection } from "@salesforce/core";
import { computeFirstStageId, fetchPipelineStages, getBranchNameFromStage, getPipelineIdForProject, findStageById, resolveTargetStageId } from "./shared/pipelineUtils.js";
import { validateSalesforceId, validateWorkItemName } from "./shared/soqlUtils.js";
import type { WorkItem } from "./types/WorkItem.js";

type ProjectStagesContext = { pipelineId: string; stages: any[]; firstStageId: string | undefined };
Expand Down Expand Up @@ -203,6 +204,9 @@ function mapRawItemToWorkItem(item: any, ctx: ProjectStagesContext | null, provi

export async function fetchWorkItems(connection: Connection, projectId: string): Promise<WorkItem[] | any> {
try {
// Validate projectId to prevent SOQL injection
const validatedProjectId = validateSalesforceId(projectId, 'projectId');

const query = `
SELECT
Id,
Expand All @@ -220,7 +224,7 @@ export async function fetchWorkItems(connection: Connection, projectId: string):
DevopsPipelineStageId,
DevopsProjectId
FROM WorkItem
WHERE DevopsProjectId = '${projectId}'
WHERE DevopsProjectId = '${validatedProjectId}'
`;


Expand All @@ -246,6 +250,9 @@ export async function fetchWorkItems(connection: Connection, projectId: string):
*/
export async function fetchWorkItemByName(connection: Connection, workItemName: string): Promise<WorkItem | null | any> {
try {
// Validate workItemName to prevent SOQL injection
const validatedWorkItemName = validateWorkItemName(workItemName);

const query = `
SELECT
Id,
Expand All @@ -263,7 +270,7 @@ export async function fetchWorkItemByName(connection: Connection, workItemName:
DevopsPipelineStageId,
DevopsProjectId
FROM WorkItem
WHERE Name = '${workItemName}'
WHERE Name = '${validatedWorkItemName}'
LIMIT 1
`;

Expand Down
14 changes: 11 additions & 3 deletions packages/mcp-provider-devops/src/getWorkItemsMP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Connection } from "@salesforce/core";
import type { WorkItem } from "./types/WorkItem.js";
import { getPipelineMP } from "./getPipelineMP.js";
import { fetchPipelineStagesMP } from "./getPipelineStagesMP.js";
import { validateSalesforceId, validateWorkItemName } from "./shared/soqlUtils.js";


function inferRepoTypeFromUrl(repoUrl: string): string {
Expand Down Expand Up @@ -56,6 +57,9 @@ export async function fetchWorkItemByNameMP(connection: Connection, workItemName
}

async function queryWorkItemByName(connection: any, workItemName: string): Promise<any | null> {
// Validate workItemName to prevent SOQL injection
const validatedWorkItemName = validateWorkItemName(workItemName);

const query = `
SELECT Id,
Name,
Expand All @@ -64,10 +68,10 @@ async function queryWorkItemByName(connection: any, workItemName: string): Promi
sf_devops__State__c,
sf_devops__Concluded__c,
sf_devops__Assigned_To__c, sf_devops__Assigned_To__r.Name,
sf_devops__Branch__c, sf_devops__Branch__r.Name,
sf_devops__Branch__c, sf_devops__Branch__r.Name,
sf_devops__Branch__r.sf_devops__Repository__r.sf_devops__Url__c, sf_devops__Project__c
FROM sf_devops__Work_Item__c
WHERE Name = '${workItemName}'
WHERE Name = '${validatedWorkItemName}'
LIMIT 1
`;
const result: any = await connection.query(query);
Expand Down Expand Up @@ -118,10 +122,14 @@ function orderStagesFromFirst(idToStage: Map<string, any>, firstStageId?: string

async function getCompletedStageIds(connection: any, workItemId: string): Promise<Set<string>> {
const completed = new Set<string>();

// Validate workItemId to prevent SOQL injection
const validatedWorkItemId = validateSalesforceId(workItemId, 'workItemId');

const q = `
SELECT Id, sf_devops__Pipeline_Stage__c, sf_devops__Deployment_Result__r.sf_devops__Completion_Date__c
FROM sf_devops__Work_Item_Promote__c
WHERE sf_devops__Work_Item__c = '${workItemId}'
WHERE sf_devops__Work_Item__c = '${validatedWorkItemId}'
AND sf_devops__Deployment_Result__r.sf_devops__Completion_Date__c != NULL
AND sf_devops__Deployment_Result__r.sf_devops__Deployment_Id__c != NULL
`;
Expand Down
114 changes: 114 additions & 0 deletions packages/mcp-provider-devops/src/shared/soqlUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Utility functions for safe SOQL query construction
*/

/**
* Validates that a string is a valid Salesforce ID (15 or 18 characters).
* Returns true if valid, false otherwise.
*/
export function isValidSalesforceId(id: string): boolean {
if (typeof id !== 'string') {
return false;
}
// Salesforce IDs are either 15 or 18 characters, alphanumeric
const trimmed = id.trim();
const idPattern = /^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/;
return idPattern.test(trimmed);
}

/**
* Escapes single quotes in a string for use in SOQL queries.
* Throws an error if the value contains SQL injection attempts.
*/
export function escapeSoqlString(value: string): string {
if (typeof value !== 'string') {
throw new Error('SOQL escape requires string input');
}

// Escape single quotes by doubling them (SOQL standard)
return value.replace(/'/g, "\\'");
}

/**
* Validates and returns a Salesforce ID, or throws an error.
* Use this for ID fields to prevent injection.
*/
export function validateSalesforceId(id: string, fieldName: string = 'ID'): string {
if (!isValidSalesforceId(id)) {
throw new Error(`Invalid Salesforce ID format for ${fieldName}: ${id}`);

Check failure on line 38 in packages/mcp-provider-devops/src/shared/soqlUtils.ts

View workflow job for this annotation

GitHub Actions / linux-unit-tests / linux-unit-tests (22)

test/getWorkItems.test.ts > fetchWorkItems > normalizes bitbucketcloud provider to bitbucket repoType

Error: Invalid Salesforce ID format for projectId: project-bitbucket-cloud ❯ validateSalesforceId src/shared/soqlUtils.ts:38:15 ❯ Module.fetchWorkItems src/getWorkItems.ts:208:36 ❯ test/getWorkItems.test.ts:132:29

Check failure on line 38 in packages/mcp-provider-devops/src/shared/soqlUtils.ts

View workflow job for this annotation

GitHub Actions / linux-unit-tests / linux-unit-tests (22)

test/getWorkItems.test.ts > fetchWorkItems > maps Bitbucket repository metadata from work items

Error: Invalid Salesforce ID format for projectId: project-bitbucket ❯ validateSalesforceId src/shared/soqlUtils.ts:38:15 ❯ Module.fetchWorkItems src/getWorkItems.ts:208:36 ❯ test/getWorkItems.test.ts:95:29

Check failure on line 38 in packages/mcp-provider-devops/src/shared/soqlUtils.ts

View workflow job for this annotation

GitHub Actions / linux-unit-tests / linux-unit-tests (22)

test/getWorkItems.test.ts > fetchWorkItems > should fetch work items when no pipeline is linked to the project

Error: Invalid Salesforce ID format for projectId: project-001 ❯ validateSalesforceId src/shared/soqlUtils.ts:38:15 ❯ Module.fetchWorkItems src/getWorkItems.ts:208:36 ❯ test/getWorkItems.test.ts:35:29

Check failure on line 38 in packages/mcp-provider-devops/src/shared/soqlUtils.ts

View workflow job for this annotation

GitHub Actions / linux-unit-tests / linux-unit-tests (22)

test/getWorkItems.test.ts > fetchWorkItems > should fetch work items successfully

Error: Invalid Salesforce ID format for projectId: project-001 ❯ validateSalesforceId src/shared/soqlUtils.ts:38:15 ❯ Module.fetchWorkItems src/getWorkItems.ts:208:36 ❯ test/getWorkItems.test.ts:28:29

Check failure on line 38 in packages/mcp-provider-devops/src/shared/soqlUtils.ts

View workflow job for this annotation

GitHub Actions / linux-unit-tests / linux-unit-tests (24.16.0)

test/getWorkItems.test.ts > fetchWorkItems > normalizes bitbucketcloud provider to bitbucket repoType

Error: Invalid Salesforce ID format for projectId: project-bitbucket-cloud ❯ validateSalesforceId src/shared/soqlUtils.ts:38:15 ❯ Module.fetchWorkItems src/getWorkItems.ts:208:36 ❯ test/getWorkItems.test.ts:132:29

Check failure on line 38 in packages/mcp-provider-devops/src/shared/soqlUtils.ts

View workflow job for this annotation

GitHub Actions / linux-unit-tests / linux-unit-tests (24.16.0)

test/getWorkItems.test.ts > fetchWorkItems > maps Bitbucket repository metadata from work items

Error: Invalid Salesforce ID format for projectId: project-bitbucket ❯ validateSalesforceId src/shared/soqlUtils.ts:38:15 ❯ Module.fetchWorkItems src/getWorkItems.ts:208:36 ❯ test/getWorkItems.test.ts:95:29

Check failure on line 38 in packages/mcp-provider-devops/src/shared/soqlUtils.ts

View workflow job for this annotation

GitHub Actions / linux-unit-tests / linux-unit-tests (24.16.0)

test/getWorkItems.test.ts > fetchWorkItems > should fetch work items when no pipeline is linked to the project

Error: Invalid Salesforce ID format for projectId: project-001 ❯ validateSalesforceId src/shared/soqlUtils.ts:38:15 ❯ Module.fetchWorkItems src/getWorkItems.ts:208:36 ❯ test/getWorkItems.test.ts:35:29

Check failure on line 38 in packages/mcp-provider-devops/src/shared/soqlUtils.ts

View workflow job for this annotation

GitHub Actions / linux-unit-tests / linux-unit-tests (24.16.0)

test/getWorkItems.test.ts > fetchWorkItems > should fetch work items successfully

Error: Invalid Salesforce ID format for projectId: project-001 ❯ validateSalesforceId src/shared/soqlUtils.ts:38:15 ❯ Module.fetchWorkItems src/getWorkItems.ts:208:36 ❯ test/getWorkItems.test.ts:28:29
}
return id.trim();
}

/**
* Validates a work item name format (WI-XXXXXXX).
* Returns true if valid, false otherwise.
*/
export function isValidWorkItemName(name: string): boolean {
if (typeof name !== 'string') {
return false;
}
// Work item names follow the pattern: WI- followed by digits
const workItemPattern = /^WI-\d+$/;
return workItemPattern.test(name.trim());
}

/**
* Checks if a string contains potential SQL injection patterns.
* Returns true if injection patterns are detected.
*/
export function containsSqlInjectionPatterns(value: string): boolean {
if (typeof value !== 'string') {
return true;
}

const trimmed = value.trim();

// Check for common SQL injection patterns
const injectionPatterns = [
/--/, // SQL comment
/;/, // Statement terminator
/\bOR\b/i, // OR keyword
/\bAND\b/i, // AND keyword
/\bUNION\b/i, // UNION keyword
/\bSELECT\b/i, // SELECT keyword
/\bDROP\b/i, // DROP keyword
/\bINSERT\b/i, // INSERT keyword
/\bUPDATE\b/i, // UPDATE keyword
/\bDELETE\b/i, // DELETE keyword
/\bEXEC\b/i, // EXEC keyword
];

// Check for injection patterns (excluding quotes which we'll escape)
const withoutQuotes = trimmed.replace(/'/g, '');
return injectionPatterns.some(pattern => pattern.test(withoutQuotes)) ||
(trimmed.includes("'") && injectionPatterns.some(pattern => pattern.test(trimmed)));
}

/**
* Validates and escapes a work item name for SOQL.
* Accepts WI-XXXXXXX format or any alphanumeric string without injection patterns.
* Throws an error if the format is invalid or contains injection attempts.
*/
export function validateWorkItemName(name: string): string {
const trimmed = name.trim();

// Check for SQL injection patterns first
if (containsSqlInjectionPatterns(trimmed)) {
throw new Error(`Invalid work item name: potential SQL injection detected in "${name}"`);
}

// If it matches the standard WI-XXXXXXX format, it's valid
if (isValidWorkItemName(trimmed)) {
return escapeSoqlString(trimmed);
}

// Also allow alphanumeric names with spaces, hyphens, and underscores (for managed package work items)
// but ensure no special characters that could be used for injection
const safeNamePattern = /^[a-zA-Z0-9\s_'-]+$/;
if (safeNamePattern.test(trimmed)) {
return escapeSoqlString(trimmed);
}

throw new Error(`Invalid work item name format: ${name}`);
}
Loading
Loading