diff --git a/client/package-lock.json b/client/package-lock.json index e6dea6f..efe6764 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4036,15 +4036,6 @@ "node": ">= 6" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -7372,9 +7363,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9198,9 +9189,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "license": "ISC" }, "node_modules/follow-redirects": { @@ -11999,9 +11990,9 @@ } }, "node_modules/jsonpath": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.2.1.tgz", - "integrity": "sha512-Jl6Jhk0jG+kP3yk59SSeGq7LFPR4JQz1DU0K+kXTysUhMostbhU3qh5mjTuf0PqFcXpAT7kvmMt9WxV10NyIgQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.3.0.tgz", + "integrity": "sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w==", "license": "MIT", "dependencies": { "esprima": "1.2.5", @@ -14494,6 +14485,15 @@ "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "license": "CC0-1.0" }, + "node_modules/postcss-svgo/node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/postcss-svgo/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -14504,17 +14504,17 @@ } }, "node_modules/postcss-svgo/node_modules/svgo": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", - "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz", + "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==", "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^4.1.3", "css-tree": "^1.1.3", "csso": "^4.2.0", "picocolors": "^1.0.0", + "sax": "^1.5.0", "stable": "^0.1.8" }, "bin": { @@ -16227,35 +16227,18 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -17149,15 +17132,14 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { diff --git a/client/src/api/apiService.js b/client/src/api/apiService.js index e015c01..d806d7a 100644 --- a/client/src/api/apiService.js +++ b/client/src/api/apiService.js @@ -1,6 +1,9 @@ import axios from 'axios'; import io from 'socket.io-client'; +// Sanitize external values before embedding in log messages to prevent log injection. +const sanitizeLog = (v) => (v == null ? '' : String(v).replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ')); + // Use the environment variables, falling back to development defaults if not set const API_URL = process.env.REACT_APP_API_URL || 'http://localhost/api'; const WS_URL = process.env.REACT_APP_WEBSOCKET_URL || 'ws://localhost'; @@ -53,7 +56,7 @@ const apiService = { const response = await api.get(`${API_URL}/workflow-runs/repo/${repoName}`, { params }); return response.data.data || { data: [], pagination: { total: 0, page: 1, pageSize: 0, totalPages: 1 } }; } catch (error) { - console.error(`Error fetching workflow runs for repo ${repoName}:`, error); + console.error("Error fetching workflow runs for repo:", sanitizeLog(repoName), error); throw error; } }, @@ -64,7 +67,7 @@ const apiService = { const response = await api.get(`${API_URL}/workflow-runs/${id}`); return response.data.data; } catch (error) { - console.error(`Error fetching workflow run ${id}:`, error); + console.error("Error fetching workflow run:", sanitizeLog(id), error); throw error; } }, @@ -75,7 +78,7 @@ const apiService = { const response = await api.post(`${API_URL}/workflow-runs/${id}/sync`); return response.data.data; } catch (error) { - console.error(`Error syncing workflow run ${id}:`, error); + console.error("Error syncing workflow run:", sanitizeLog(id), error); throw error; } }, @@ -85,7 +88,7 @@ const apiService = { const response = await api.delete(`${API_URL}/workflow-runs/${id}`); return response.data.data; } catch (error) { - console.error(`Error deleting workflow run ${id}:`, error); + console.error("Error deleting workflow run:", sanitizeLog(id), error); throw error; } }, @@ -96,7 +99,7 @@ const apiService = { const response = await api.post(`${API_URL}/workflow-runs/repo/${repoName}/sync`); return response.data.data; } catch (error) { - console.error(`Error syncing workflow runs for repo ${repoName}:`, error); + console.error("Error syncing workflow runs for repo:", sanitizeLog(repoName), error); throw error; } }, diff --git a/server/package-lock.json b/server/package-lock.json index bc22a5f..03d6dd0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1071,12 +1071,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -1324,9 +1324,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -2096,22 +2096,22 @@ "license": "MIT" }, "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" diff --git a/server/server.js b/server/server.js index d748b5c..72c0009 100644 --- a/server/server.js +++ b/server/server.js @@ -104,11 +104,14 @@ app.post('/api/webhooks/github', async (req, res) => { const name = req.headers['x-github-event']; const contentType = req.headers['content-type']; + // Sanitize header values before logging to prevent log injection via CRLF. + const sanitize = (v) => (v == null ? '' : String(v).replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ')); + console.log('Received webhook POST request from GitHub'); - console.log('Event:', name); - console.log('Delivery ID:', id); - console.log('Content-Type:', contentType); - console.log('Signature:', signature); + console.log('Event: %s', sanitize(name)); + console.log('Delivery ID: %s', sanitize(id)); + console.log('Content-Type: %s', sanitize(contentType)); + console.log('Signature present:', Boolean(signature)); if (!signature || !id || !name) { console.error('Missing required webhook headers'); diff --git a/server/src/controllers/workflowController.js b/server/src/controllers/workflowController.js index 56eee81..131f54a 100644 --- a/server/src/controllers/workflowController.js +++ b/server/src/controllers/workflowController.js @@ -28,12 +28,15 @@ export const requireAdminToken = (req, res, next) => { } // Use constant-time comparison to prevent timing attacks. - // Hash both tokens to a fixed length (SHA-256) first to eliminate length-based timing leaks. + // Pad both values to equal length so timingSafeEqual works without hashing. try { - const adminHash = crypto.createHash('sha256').update(adminToken).digest(); - const providedHash = crypto.createHash('sha256').update(provided).digest(); + const maxLen = Math.max(adminToken.length, provided.length); + const adminBuf = Buffer.alloc(maxLen); + const providedBuf = Buffer.alloc(maxLen); + Buffer.from(adminToken).copy(adminBuf); + Buffer.from(provided).copy(providedBuf); - if (!crypto.timingSafeEqual(adminHash, providedHash)) { + if (adminToken.length !== provided.length || !crypto.timingSafeEqual(adminBuf, providedBuf)) { return res.status(401).json({ error: 'Unauthorized: valid admin token required.' }); } } catch (err) { @@ -125,18 +128,29 @@ export const getAllWorkflowRuns = async (req, res) => { } }; +// Validates that a repo path matches the expected owner/repo format. +// Prevents NoSQL injection by rejecting paths with MongoDB operator characters. +const REPO_PATH_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; + export const getRepoWorkflowRuns = async (req, res) => { try { const repoPath = req.params[0]; - const { workflowName } = req.query; // Get workflowName from query params + // Ensure workflowName is a plain string — qs can parse ?workflowName[$ne]=x into an + // object, which would be passed directly into a MongoDB query (NoSQL injection). + const workflowName = typeof req.query.workflowName === 'string' ? req.query.workflowName : null; if (!repoPath) { return errorResponse(res, 'Repository name is required', 400); } + // Validate repoPath to prevent NoSQL injection — only allow owner/repo format + if (!REPO_PATH_RE.test(repoPath)) { + return errorResponse(res, 'Invalid repository path format', 400); + } + // First get the repository document to get all workflows const repoDoc = await WorkflowRun.findOne({ 'repository.fullName': repoPath }); - + if (!repoDoc) { return successResponse(res, { data: [], @@ -293,6 +307,11 @@ export const syncRepositoryWorkflowRuns = async (req, res) => { return errorResponse(res, 'Repository name is required', 400); } + // Validate repoPath to prevent NoSQL injection — only allow owner/repo format + if (!REPO_PATH_RE.test(repoPath)) { + return errorResponse(res, 'Invalid repository path format', 400); + } + const workflowRuns = await workflowService.syncRepositoryWorkflowRuns(repoPath); // After sync is complete, emit updates for each workflow run diff --git a/server/src/services/syncService.js b/server/src/services/syncService.js index 21a2c6e..ed717d5 100644 --- a/server/src/services/syncService.js +++ b/server/src/services/syncService.js @@ -2,6 +2,10 @@ import { getGitHubClient } from '../utils/githubAuth.js'; import * as workflowService from './workflowService.js'; import SyncHistory from '../models/SyncHistory.js'; +// Strip CR/LF and other control characters from values before embedding in log messages +// to prevent log injection attacks. +const sanitizeLog = (v) => (v == null ? '' : String(v).replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ')); + // Mark any existing in_progress syncs as interrupted when starting up const markInterruptedSyncs = async () => { try { @@ -48,7 +52,7 @@ export const syncGitHubData = async (installationId, socket, options = { maxWork throw new Error('Installation ID is required for synchronization'); } - console.log('Starting sync for installation ID:', installationId); + console.log('Starting sync for installation ID: %s', sanitizeLog(installationId)); let syncRecord; try { @@ -65,7 +69,7 @@ export const syncGitHubData = async (installationId, socket, options = { maxWork }); const orgName = installation.account.login; - console.log('Organization name:', orgName); + console.log('Organization name:', sanitizeLog(orgName)); // Create sync record syncRecord = await SyncHistory.create({ @@ -357,7 +361,7 @@ export const syncGitHubData = async (installationId, socket, options = { maxWork runs.push(...runsPage); page++; } catch (error) { - console.error(`Error fetching runs for workflow ${workflow.name}:`, error); + console.error("Error fetching runs for workflow:", sanitizeLog(workflow.name), error); break; } } @@ -389,12 +393,7 @@ export const syncGitHubData = async (installationId, socket, options = { maxWork // Process each job if (jobs && jobs.length > 0) { for (const job of jobs) { - console.log(`Processing job ${job.id} for run ${run.run_number}:`, { - labels: job.labels, - runLabels: Array.from(allLabels), - jobName: job.name, - runNumber: run.run_number - }); + console.log("Processing job", sanitizeLog(job.id), "for run", sanitizeLog(run.run_number)); const jobPayload = { action: 'completed', @@ -460,7 +459,7 @@ export const syncGitHubData = async (installationId, socket, options = { maxWork } ); } catch (error) { - console.error(`Error processing run ${run.id}:`, error); + console.error("Error processing run:", sanitizeLog(run.id), error); results.errors.push({ type: 'run', id: run.id, @@ -470,7 +469,7 @@ export const syncGitHubData = async (installationId, socket, options = { maxWork } } } catch (error) { - console.error(`Error processing workflow ${workflow.name}:`, error); + console.error("Error processing workflow:", sanitizeLog(workflow.name), error); results.errors.push({ type: 'workflow', name: workflow.name, @@ -480,7 +479,7 @@ export const syncGitHubData = async (installationId, socket, options = { maxWork } } catch (error) { - console.error(`Error processing repository ${repo.name}:`, error); + console.error("Error processing repository:", sanitizeLog(repo.name), error); results.errors.push({ type: 'repository', name: repo.name, diff --git a/server/src/services/workflowService.js b/server/src/services/workflowService.js index f42101d..d0ad3f4 100644 --- a/server/src/services/workflowService.js +++ b/server/src/services/workflowService.js @@ -1,5 +1,9 @@ import WorkflowRun from '../models/WorkflowRun.js'; +// Strip CR/LF and other control characters from values before embedding in log messages +// to prevent log injection attacks. +const sanitizeLog = (v) => (v == null ? '' : String(v).replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ')); + const transformGitHubUrl = (apiUrl) => { if (!apiUrl) return ''; // Transform from API URL to web interface URL @@ -50,7 +54,7 @@ export const processWorkflowRun = async (payload) => { console.log('Processing workflow run with labels:', { id: run.id, labels: workflowRunData.run.labels, - status: run.status + status: sanitizeLog(run.status) }); // Find existing run to preserve any existing labels and jobs if none provided @@ -96,8 +100,8 @@ export const updateWorkflowJobs = async (runId, jobs) => { runner_name: job.runner_name, runner_group_id: job.runner_group_id, runner_group_name: job.runner_group_name, - runner_os: job.runner_os || (job.labels?.find(l => l.includes('ubuntu') || l.includes('windows') || l.includes('macos')) || '').split('-')[0], - runner_version: job.labels?.find(l => l.includes('(') && l.includes(')'))?.match(/\((.*?)\)/)?.[1] || '', + runner_os: job.runner_os || (job.labels?.find(l => l.startsWith('ubuntu') || l.startsWith('windows') || l.startsWith('macos')) || '').split('-')[0], + runner_version: (() => { const s = job.labels?.find(l => l.includes('(') && l.includes(')')); if (!s) return ''; const start = s.indexOf('('); const end = s.indexOf(')', start + 1); return start !== -1 && end !== -1 ? s.slice(start + 1, end) : ''; })(), runner_image_version: job.labels?.find(l => l.includes('-'))?.split('-')[1] || '', steps: job.steps.map(step => ({ name: step.name, @@ -201,7 +205,7 @@ export const processWorkflowJobEvent = async (payload) => { jobId: workflow_job.id, runId: workflow_job.run_id, labels: workflow_job.labels, - status: workflow_job.status + status: sanitizeLog(workflow_job.status) }); const processGitHubRunnerInfo = (job) => { @@ -210,7 +214,7 @@ export const processWorkflowJobEvent = async (payload) => { let imageVersion = ''; if (job.labels) { - const osLabel = job.labels.find(l => l.match(/^(ubuntu|windows|macos)/)); + const osLabel = job.labels.find(l => l.startsWith('ubuntu') || l.startsWith('windows') || l.startsWith('macos')); if (osLabel) { const [os, version] = osLabel.split('-'); runnerOs = os; @@ -219,9 +223,10 @@ export const processWorkflowJobEvent = async (payload) => { const githubLabel = job.labels.find(l => l.includes('GitHub Actions')); if (githubLabel) { - const match = githubLabel.match(/\((.*?)\)/); - if (match) { - runnerVersion = match[1]; + const start = githubLabel.indexOf('('); + const end = githubLabel.indexOf(')', start + 1); + if (start !== -1 && end !== -1) { + runnerVersion = githubLabel.slice(start + 1, end); } } }