mcp-server-semgrep
Version:
MCP Server for Semgrep Integration - static code analysis with AI
633 lines (632 loc) • 28.2 kB
JavaScript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
import { execFile } from 'child_process';
import { existsSync, realpathSync } from 'fs';
import { promisify } from 'util';
import { readFile, writeFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const execFileAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
export const ALLOWED_ROOTS_ENV = 'MCP_SERVER_SEMGREP_ALLOWED_ROOTS';
export const BASE_ALLOWED_PATH = path.resolve(process.cwd());
const SEMGREP_MAX_BUFFER = 50 * 1024 * 1024;
const CONTROL_CHARACTERS = /[\0\n\r\t]/;
const REGISTRY_CONFIG_REF = /^(?:[pr]\/[A-Za-z0-9._/-]+|auto)$/;
const ALLOWED_SEVERITY = new Set(['ERROR', 'WARNING', 'INFO']);
const ALLOWED_LANGUAGE = /^[a-zA-Z][a-zA-Z0-9_+-]{0,31}$/;
const ALLOWED_RULE_ID = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
export function validateNoShellMetacharacters(value, paramName) {
if (CONTROL_CHARACTERS.test(value)) {
throw new McpError(ErrorCode.InvalidParams, `${paramName} contains control characters that are not allowed`);
}
}
function resolvePathForValidation(candidatePath) {
const absolutePath = path.resolve(candidatePath);
let existingPath = absolutePath;
const missingSegments = [];
while (!existsSync(existingPath)) {
const parentPath = path.dirname(existingPath);
if (parentPath === existingPath) {
break;
}
missingSegments.unshift(path.basename(existingPath));
existingPath = parentPath;
}
const resolvedExistingPath = realpathSync.native(existingPath);
return path.resolve(resolvedExistingPath, ...missingSegments);
}
function isPathWithinRoot(rootPath, candidatePath) {
const relativePath = path.relative(rootPath, candidatePath);
return relativePath === '' || (!relativePath.startsWith('..') &&
!path.isAbsolute(relativePath));
}
function formatAllowedRoots(allowedRoots) {
return allowedRoots.length === 1 ? allowedRoots[0] : allowedRoots.join(', ');
}
export function getAllowedRoots() {
const configuredRoots = process.env[ALLOWED_ROOTS_ENV]
?.split(path.delimiter)
.map((rootPath) => rootPath.trim())
.filter(Boolean)
.map((rootPath) => resolvePathForValidation(rootPath));
const allowedRoots = configuredRoots?.length
? configuredRoots
: [resolvePathForValidation(BASE_ALLOWED_PATH)];
return Array.from(new Set(allowedRoots));
}
export function parseSemgrepResults(fileContent) {
const parsedContent = JSON.parse(fileContent);
if (!parsedContent || typeof parsedContent !== 'object' || Array.isArray(parsedContent)) {
return {};
}
return parsedContent;
}
function getSemgrepFindings(results) {
return Array.isArray(results.results) ? results.results : [];
}
function validateRegistryConfig(configValue) {
validateNoShellMetacharacters(configValue, 'config');
if (!REGISTRY_CONFIG_REF.test(configValue)) {
throw new McpError(ErrorCode.InvalidParams, 'config must be "auto" or a Semgrep registry reference like "p/security"');
}
return configValue;
}
export function validateAbsolutePath(pathToValidate, paramName) {
if (paramName === 'config' && (pathToValidate.startsWith('p/') ||
pathToValidate.startsWith('r/') ||
pathToValidate === 'auto')) {
return validateRegistryConfig(pathToValidate);
}
validateNoShellMetacharacters(pathToValidate, paramName);
if (!path.isAbsolute(pathToValidate)) {
throw new McpError(ErrorCode.InvalidParams, `${paramName} must be an absolute path. Received: ${pathToValidate}`);
}
const normalizedPath = resolvePathForValidation(pathToValidate);
if (!path.isAbsolute(normalizedPath)) {
throw new McpError(ErrorCode.InvalidParams, `${paramName} contains invalid path traversal sequences`);
}
const allowedRoots = getAllowedRoots();
const isWithinAllowedRoots = allowedRoots.some((allowedRoot) => isPathWithinRoot(allowedRoot, normalizedPath));
if (!isWithinAllowedRoots) {
throw new McpError(ErrorCode.InvalidParams, `${paramName} must be within an allowed workspace root (${formatAllowedRoots(allowedRoots)})`);
}
return normalizedPath;
}
export function validateRuleField(value, paramName, pattern) {
if (typeof value !== 'string' || !pattern.test(value)) {
throw new McpError(ErrorCode.InvalidParams, `${paramName} contains invalid characters or exceeds allowed format`);
}
return value;
}
export function validateRuleSeverity(value) {
const upper = String(value).toUpperCase();
if (!ALLOWED_SEVERITY.has(upper)) {
throw new McpError(ErrorCode.InvalidParams, `severity must be one of: ${Array.from(ALLOWED_SEVERITY).join(', ')}`);
}
return upper;
}
function escapeYamlScalar(value) {
if (typeof value !== 'string') {
throw new McpError(ErrorCode.InvalidParams, 'YAML scalar value must be a string');
}
return JSON.stringify(value);
}
class SemgrepServer {
constructor() {
this.server = new Server({
name: 'mcp-server-semgrep',
version: '1.0.1',
}, {
capabilities: {
tools: {},
},
});
this.setupToolHandlers();
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
async checkSemgrepInstallation() {
try {
await execFileAsync('semgrep', ['--version']);
return true;
}
catch {
return false;
}
}
async installSemgrep() {
console.error('Installing Semgrep...');
try {
await execFileAsync('pip3', ['--version']);
}
catch {
throw new Error('Python/pip3 is not installed. Please install Python and pip3.');
}
try {
await execFileAsync('pip3', ['install', 'semgrep']);
console.error('Semgrep was successfully installed');
}
catch (error) {
throw new Error(`Error installing Semgrep: ${error.message}`);
}
}
async ensureSemgrepAvailable() {
const isInstalled = await this.checkSemgrepInstallation();
if (!isInstalled) {
await this.installSemgrep();
}
}
setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'scan_directory',
description: 'Performs a Semgrep scan on a directory',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Absolute path to the directory to scan (must be within an allowed workspace root)'
},
config: {
type: 'string',
description: 'Semgrep configuration (e.g. "auto" or absolute path to rule file)',
default: 'auto'
}
},
required: ['path']
}
},
{
name: 'list_rules',
description: 'Lists available Semgrep rules',
inputSchema: {
type: 'object',
properties: {
language: {
type: 'string',
description: 'Programming language for rules (optional)'
}
}
}
},
{
name: 'analyze_results',
description: 'Analyzes scan results',
inputSchema: {
type: 'object',
properties: {
results_file: {
type: 'string',
description: 'Absolute path to JSON results file (must be within an allowed workspace root)'
}
},
required: ['results_file']
}
},
{
name: 'create_rule',
description: 'Creates a new Semgrep rule',
inputSchema: {
type: 'object',
properties: {
output_path: { type: 'string', description: 'Absolute path for output rule file' },
pattern: { type: 'string', description: 'Search pattern for the rule' },
language: { type: 'string', description: 'Target language for the rule' },
message: { type: 'string', description: 'Message to display when rule matches' },
severity: {
type: 'string',
description: 'Rule severity (ERROR, WARNING, INFO)',
default: 'WARNING'
},
id: {
type: 'string',
description: 'Rule identifier',
default: 'custom_rule'
}
},
required: ['output_path', 'pattern', 'language', 'message']
}
},
{
name: 'filter_results',
description: 'Filters scan results by various criteria',
inputSchema: {
type: 'object',
properties: {
results_file: { type: 'string', description: 'Absolute path to JSON results file' },
severity: { type: 'string', description: 'Filter by severity (ERROR, WARNING, INFO)' },
rule_id: { type: 'string', description: 'Filter by rule ID' },
path_pattern: { type: 'string', description: 'Filter by file path pattern (regex)' },
language: { type: 'string', description: 'Filter by programming language' },
message_pattern: { type: 'string', description: 'Filter by message content (regex)' }
},
required: ['results_file']
}
},
{
name: 'export_results',
description: 'Exports scan results in various formats',
inputSchema: {
type: 'object',
properties: {
results_file: { type: 'string', description: 'Absolute path to JSON results file' },
output_file: { type: 'string', description: 'Absolute path to output file' },
format: {
type: 'string',
description: 'Output format (json, sarif, text)',
default: 'text'
}
},
required: ['results_file', 'output_file']
}
},
{
name: 'compare_results',
description: 'Compares two scan results',
inputSchema: {
type: 'object',
properties: {
old_results: { type: 'string', description: 'Absolute path to older JSON results file' },
new_results: { type: 'string', description: 'Absolute path to newer JSON results file' }
},
required: ['old_results', 'new_results']
}
}
]
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
await this.ensureSemgrepAvailable();
switch (request.params.name) {
case 'scan_directory':
return await this.handleScanDirectory(request.params.arguments);
case 'list_rules':
return await this.handleListRules(request.params.arguments);
case 'analyze_results':
return await this.handleAnalyzeResults(request.params.arguments);
case 'create_rule':
return await this.handleCreateRule(request.params.arguments);
case 'filter_results':
return await this.handleFilterResults(request.params.arguments);
case 'export_results':
return await this.handleExportResults(request.params.arguments);
case 'compare_results':
return await this.handleCompareResults(request.params.arguments);
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
});
}
async handleScanDirectory(args) {
if (!args.path) {
throw new McpError(ErrorCode.InvalidParams, 'Path is required');
}
const scanPath = validateAbsolutePath(args.path, 'path');
const config = args.config || 'auto';
const configParam = validateAbsolutePath(config, 'config');
try {
// Use execFile with arg array to prevent shell injection (CWE-78 fix)
const semgrepArgs = ['scan', '--json', '--config', configParam, scanPath];
if (process.env.SEMGREP_APP_TOKEN && config.startsWith('r/')) {
semgrepArgs.splice(1, 0, '--oauth-token', process.env.SEMGREP_APP_TOKEN);
}
const loggedArgs = semgrepArgs.map((arg, idx) => idx > 0 && semgrepArgs[idx - 1] === '--oauth-token' ? '[REDACTED]' : arg);
console.error(`Executing: semgrep ${loggedArgs.join(' ')}`);
const { stdout } = await execFileAsync('semgrep', semgrepArgs, {
maxBuffer: SEMGREP_MAX_BUFFER,
});
return {
content: [{ type: 'text', text: stdout }]
};
}
catch (error) {
return {
content: [{ type: 'text', text: `Error scanning: ${error.message}` }],
isError: true
};
}
}
async handleListRules(args) {
try {
const hasToken = Boolean(process.env.SEMGREP_APP_TOKEN);
const languageNote = args.language && ALLOWED_LANGUAGE.test(args.language)
? `\n(Filter requested for language: ${args.language})\n`
: '';
let rulesList = `Available Semgrep Registry Rules:
${languageNote}
Standard rule collections:
- p/ci: Basic CI rules
- p/security: Security rules
- p/performance: Performance rules
- p/best-practices: Best practice rules
`;
if (hasToken) {
rulesList += `
Pro Rule Collections (available with your SEMGREP_APP_TOKEN):
- r/java.lang.security.audit.crypto.ssl.weak-protocol
- r/javascript.express.security.audit.cookie-session-no-secure
- r/go.lang.security.audit.crypto.bad_imports
- And many more...
`;
}
rulesList += `
Use these rule collections with --config, e.g.:
semgrep scan --config=p/ci`;
return {
content: [{ type: 'text', text: rulesList }]
};
}
catch (error) {
return {
content: [{ type: 'text', text: `Error retrieving rules: ${error.message}` }],
isError: true
};
}
}
async handleAnalyzeResults(args) {
if (!args.results_file) {
throw new McpError(ErrorCode.InvalidParams, 'Results file is required');
}
const resultsFile = validateAbsolutePath(args.results_file, 'results_file');
try {
const fileContent = await readFile(resultsFile, 'utf-8');
const results = parseSemgrepResults(fileContent);
const findings = getSemgrepFindings(results);
const summary = {
total_findings: findings.length,
by_severity: {},
by_rule: {}
};
for (const finding of findings) {
const severity = finding.extra?.severity || 'unknown';
const rule = finding.check_id || 'unknown';
summary.by_severity[severity] = (summary.by_severity[severity] || 0) + 1;
summary.by_rule[rule] = (summary.by_rule[rule] || 0) + 1;
}
return {
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }]
};
}
catch (error) {
return {
content: [{ type: 'text', text: `Error analyzing results: ${error.message}` }],
isError: true
};
}
}
async handleCreateRule(args) {
if (!args.output_path || !args.pattern || !args.language || !args.message) {
throw new McpError(ErrorCode.InvalidParams, 'output_path, pattern, language and message are required');
}
const outputPath = validateAbsolutePath(args.output_path, 'output_path');
const id = validateRuleField(args.id ?? 'custom_rule', 'id', ALLOWED_RULE_ID);
const language = validateRuleField(args.language, 'language', ALLOWED_LANGUAGE);
const severity = validateRuleSeverity(args.severity ?? 'WARNING');
// escapeYamlScalar uses JSON.stringify to escape user values, preventing YAML injection
const ruleYaml = [
'rules:',
` - id: ${id}`,
` pattern: ${escapeYamlScalar(args.pattern)}`,
` message: ${escapeYamlScalar(args.message)}`,
` languages: [${language}]`,
` severity: ${severity}`,
''
].join('\n');
try {
await writeFile(outputPath, ruleYaml, 'utf-8');
return {
content: [{ type: 'text', text: `Rule successfully created at ${outputPath}` }]
};
}
catch (error) {
return {
content: [{ type: 'text', text: `Error creating rule: ${error.message}` }],
isError: true
};
}
}
async handleFilterResults(args) {
if (!args.results_file) {
throw new McpError(ErrorCode.InvalidParams, 'results_file is required');
}
const resultsFile = validateAbsolutePath(args.results_file, 'results_file');
try {
const fileContent = await readFile(resultsFile, 'utf-8');
const results = parseSemgrepResults(fileContent);
let filteredResults = getSemgrepFindings(results);
if (args.severity) {
filteredResults = filteredResults.filter((finding) => finding.extra?.severity === args.severity);
}
if (args.rule_id) {
filteredResults = filteredResults.filter((finding) => finding.check_id === args.rule_id);
}
if (args.path_pattern) {
const pathRegex = new RegExp(args.path_pattern);
filteredResults = filteredResults.filter((finding) => pathRegex.test(finding.path));
}
if (args.language) {
filteredResults = filteredResults.filter((finding) => finding.extra?.metadata?.language === args.language);
}
if (args.message_pattern) {
const messageRegex = new RegExp(args.message_pattern);
filteredResults = filteredResults.filter((finding) => messageRegex.test(finding.extra?.message ?? ''));
}
return {
content: [{ type: 'text', text: JSON.stringify({ results: filteredResults }, null, 2) }]
};
}
catch (error) {
return {
content: [{ type: 'text', text: `Error filtering results: ${error.message}` }],
isError: true
};
}
}
async handleExportResults(args) {
if (!args.results_file || !args.output_file) {
throw new McpError(ErrorCode.InvalidParams, 'results_file and output_file are required');
}
const resultsFile = validateAbsolutePath(args.results_file, 'results_file');
const outputFile = validateAbsolutePath(args.output_file, 'output_file');
const format = args.format || 'text';
try {
const fileContent = await readFile(resultsFile, 'utf-8');
const results = parseSemgrepResults(fileContent);
const findings = getSemgrepFindings(results);
let output = '';
switch (format) {
case 'json':
output = JSON.stringify(results, null, 2);
break;
case 'sarif': {
const sarifOutput = {
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
version: '2.1.0',
runs: [{
tool: {
driver: {
name: 'semgrep',
rules: findings.map((r) => ({
id: r.check_id,
name: r.check_id,
shortDescription: { text: r.extra?.message ?? '' },
defaultConfiguration: {
level: r.extra?.severity === 'ERROR' ? 'error' : 'warning'
}
}))
}
},
results: findings.map((r) => ({
ruleId: r.check_id,
message: { text: r.extra?.message ?? '' },
locations: [{
physicalLocation: {
artifactLocation: { uri: r.path },
region: {
startLine: r.start?.line,
startColumn: r.start?.col,
endLine: r.end?.line,
endColumn: r.end?.col
}
}
}]
}))
}]
};
output = JSON.stringify(sarifOutput, null, 2);
break;
}
case 'text':
default:
output = findings.map((r) => `[${r.extra?.severity ?? 'unknown'}] ${r.check_id}\n` +
`File: ${r.path}\n` +
`Lines: ${r.start?.line}-${r.end?.line}\n` +
`Message: ${r.extra?.message ?? ''}\n` +
'-------------------').join('\n');
break;
}
await writeFile(outputFile, output, 'utf-8');
return {
content: [{ type: 'text', text: `Results successfully exported to ${outputFile}` }]
};
}
catch (error) {
return {
content: [{ type: 'text', text: `Error exporting results: ${error.message}` }],
isError: true
};
}
}
async handleCompareResults(args) {
if (!args.old_results || !args.new_results) {
throw new McpError(ErrorCode.InvalidParams, 'old_results and new_results are required');
}
const oldResultsFile = validateAbsolutePath(args.old_results, 'old_results');
const newResultsFile = validateAbsolutePath(args.new_results, 'new_results');
try {
const [oldContent, newContent] = await Promise.all([
readFile(oldResultsFile, 'utf-8'),
readFile(newResultsFile, 'utf-8'),
]);
const oldResults = getSemgrepFindings(parseSemgrepResults(oldContent));
const newResults = getSemgrepFindings(parseSemgrepResults(newContent));
const oldFindings = new Set(oldResults.map((r) => `${r.check_id}:${r.path}:${r.start?.line}:${r.start?.col}`));
const comparison = {
total_old: oldResults.length,
total_new: newResults.length,
added: [],
removed: [],
unchanged: []
};
newResults.forEach((finding) => {
const key = `${finding.check_id}:${finding.path}:${finding.start?.line}:${finding.start?.col}`;
if (oldFindings.has(key)) {
comparison.unchanged.push(finding);
}
else {
comparison.added.push(finding);
}
});
const newKeys = new Set(newResults.map((r) => `${r.check_id}:${r.path}:${r.start?.line}:${r.start?.col}`));
oldResults.forEach((finding) => {
const key = `${finding.check_id}:${finding.path}:${finding.start?.line}:${finding.start?.col}`;
if (!newKeys.has(key)) {
comparison.removed.push(finding);
}
});
return {
content: [{
type: 'text',
text: JSON.stringify({
summary: {
old_findings: comparison.total_old,
new_findings: comparison.total_new,
added: comparison.added.length,
removed: comparison.removed.length,
unchanged: comparison.unchanged.length
},
details: comparison
}, null, 2)
}]
};
}
catch (error) {
return {
content: [{ type: 'text', text: `Error comparing results: ${error.message}` }],
isError: true
};
}
}
async run() {
try {
await this.ensureSemgrepAvailable();
}
catch (error) {
console.error(`Error setting up Semgrep: ${error.message}`);
process.exit(1);
}
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error(`MCP Server Semgrep running on stdio (allowed roots: ${formatAllowedRoots(getAllowedRoots())})`);
}
}
const isEntrypoint = (() => {
try {
return process.argv[1] && path.resolve(process.argv[1]) === __filename;
}
catch {
return false;
}
})();
if (isEntrypoint) {
const server = new SemgrepServer();
server.run().catch(console.error);
}
export { SemgrepServer };