|
| 1 | +/** |
| 2 | + * Copyright 2026 GitProxy Contributors |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | + |
| 17 | +/* |
| 18 | + ** Plugin that checks if any vulnerable dependency is used in a git repo |
| 19 | + ** Uses OWASP's dependency-check to achieve this |
| 20 | + ** The filtering strictness of the plugin can be decided by the user |
| 21 | + ** by using the "dependencyVulnThreshold" key in config JSON. |
| 22 | + ** "dependencyVulnThreshold" decides the lower bound to the filtering. |
| 23 | + ** So, if "dependencyVulnThreshold" is "LOW", any vulnerabilities of level LOW or higher |
| 24 | + ** would block the push |
| 25 | + ** Allowed values for dependencyVulnThreshold are info, low, medium, high, critical |
| 26 | + ** NOTE: This plugin expects dependency-check to be installed and in the |
| 27 | + ** path environment variable |
| 28 | + */ |
| 29 | + |
| 30 | +import { PushActionPlugin } from '@finos/git-proxy/plugin'; |
| 31 | +import { Step } from '@finos/git-proxy/proxy/actions'; |
| 32 | +import { spawn, spawnSync } from 'node:child_process'; |
| 33 | +import fs from 'node:fs'; |
| 34 | +import path from 'node:path'; |
| 35 | + |
| 36 | +const SEVERITY_LEVELS = { |
| 37 | + critical: 5, |
| 38 | + high: 4, |
| 39 | + medium: 3, |
| 40 | + low: 2, |
| 41 | + info: 1, |
| 42 | +}; |
| 43 | + |
| 44 | +const EMPTY_COMMIT_HASH = '0000000000000000000000000000000000000000'; |
| 45 | +const EMPTY_TREE_HASH = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; |
| 46 | + |
| 47 | +/** |
| 48 | + * Run a command asynchronously, collecting stdout/stderr. |
| 49 | + * @param {string} cwd Working directory |
| 50 | + * @param {string} command Executable to run |
| 51 | + * @param {string[]} args Arguments |
| 52 | + * @param {object} options Additional spawn options |
| 53 | + * @return {Promise<{exitCode: number|null, stdout: string, stderr: string}>} |
| 54 | + */ |
| 55 | +function runCommand(cwd, command, args = [], options = {}) { |
| 56 | + return new Promise((resolve, reject) => { |
| 57 | + const child = spawn(command, args, { cwd, ...options }); |
| 58 | + let stdout = ''; |
| 59 | + let stderr = ''; |
| 60 | + child.stdout.on('data', (data) => { |
| 61 | + stdout += data.toString(); |
| 62 | + }); |
| 63 | + child.stderr.on('data', (data) => { |
| 64 | + stderr += data.toString(); |
| 65 | + }); |
| 66 | + child.on('close', (exitCode) => resolve({ exitCode, stdout, stderr })); |
| 67 | + child.on('error', reject); |
| 68 | + }); |
| 69 | +} |
| 70 | + |
| 71 | +class CheckDependencyVulnPlugin extends PushActionPlugin { |
| 72 | + constructor() { |
| 73 | + super(async function exec(req, action) { |
| 74 | + const step = new Step('checkDependencyVulnPlugin'); |
| 75 | + |
| 76 | + const thresholdKey = (process.env.DEPENDENCY_VULN_THRESHOLD || 'HIGH').toLowerCase(); |
| 77 | + const minLevel = SEVERITY_LEVELS[thresholdKey] ?? SEVERITY_LEVELS.high; |
| 78 | + |
| 79 | + // Unique temp directory per push to avoid collisions under concurrent requests |
| 80 | + const tempDir = path.join('.tempRepo', String(action.timestamp)); |
| 81 | + |
| 82 | + try { |
| 83 | + // Build clone URL with credentials from the Authorization header, |
| 84 | + // mirroring the approach used by the pullRemote processor |
| 85 | + let cloneUrl = action.url; |
| 86 | + const authHeader = req.headers?.authorization; |
| 87 | + if (authHeader?.startsWith('Basic ')) { |
| 88 | + const credentials = Buffer.from(authHeader.slice(6), 'base64').toString(); |
| 89 | + const colonIdx = credentials.indexOf(':'); |
| 90 | + if (colonIdx !== -1) { |
| 91 | + const username = encodeURIComponent(credentials.slice(0, colonIdx)); |
| 92 | + const password = encodeURIComponent(credentials.slice(colonIdx + 1)); |
| 93 | + const urlObj = new URL(action.url); |
| 94 | + urlObj.username = username; |
| 95 | + urlObj.password = password; |
| 96 | + cloneUrl = urlObj.toString(); |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + fs.mkdirSync(tempDir, { recursive: true }); |
| 101 | + |
| 102 | + // Clone the remote repository as a bare clone |
| 103 | + step.log(`Cloning ${action.url} for dependency scan`); |
| 104 | + const cloneResult = await runCommand(tempDir, 'git', [ |
| 105 | + 'clone', |
| 106 | + cloneUrl, |
| 107 | + action.repoName, |
| 108 | + '--bare', |
| 109 | + ]); |
| 110 | + |
| 111 | + if (cloneResult.exitCode !== 0) { |
| 112 | + step.setError(`Failed to clone repository for dependency scan: ${cloneResult.stderr}`); |
| 113 | + action.addStep(step); |
| 114 | + return action; |
| 115 | + } |
| 116 | + |
| 117 | + // Apply the pushed pack data to the local bare clone. |
| 118 | + // req.body is the raw pack buffer, set by proxyFilter before the chain runs. |
| 119 | + spawnSync('git', ['receive-pack', action.repoName], { |
| 120 | + cwd: tempDir, |
| 121 | + input: req.body, |
| 122 | + maxBuffer: 50 * 1024 * 1024, |
| 123 | + }); |
| 124 | + |
| 125 | + const repoDir = path.join(tempDir, action.repoName); |
| 126 | + |
| 127 | + // Resolve the base commit for the diff, matching the logic in getDiff.ts |
| 128 | + let commitFrom = EMPTY_TREE_HASH; |
| 129 | + if (action.commitFrom === EMPTY_COMMIT_HASH) { |
| 130 | + const lastParent = action.commitData?.[action.commitData.length - 1]?.parent; |
| 131 | + if (lastParent && lastParent !== EMPTY_COMMIT_HASH) { |
| 132 | + commitFrom = lastParent; |
| 133 | + } |
| 134 | + } else { |
| 135 | + commitFrom = action.commitFrom; |
| 136 | + } |
| 137 | + |
| 138 | + // Get files added or modified by this push |
| 139 | + const diffResult = spawnSync('git', ['diff', '--name-only', commitFrom, action.commitTo], { |
| 140 | + cwd: repoDir, |
| 141 | + encoding: 'utf-8', |
| 142 | + maxBuffer: 50 * 1024 * 1024, |
| 143 | + }); |
| 144 | + |
| 145 | + const changedFiles = diffResult.stdout.split('\n').filter((f) => f.trim() !== ''); |
| 146 | + step.log(`Changed files: ${changedFiles.join(', ')}`); |
| 147 | + |
| 148 | + if (changedFiles.length === 0) { |
| 149 | + step.log('No changed files to scan for dependency vulnerabilities.'); |
| 150 | + action.addStep(step); |
| 151 | + return action; |
| 152 | + } |
| 153 | + |
| 154 | + // Extract the content of changed files from the pushed commit into a |
| 155 | + // staging directory for dependency-check to scan |
| 156 | + const scanInputDir = path.join(tempDir, 'scan-input'); |
| 157 | + fs.mkdirSync(scanInputDir, { recursive: true }); |
| 158 | + |
| 159 | + for (const filePath of changedFiles) { |
| 160 | + const showResult = spawnSync('git', ['show', `${action.commitTo}:${filePath}`], { |
| 161 | + cwd: repoDir, |
| 162 | + encoding: 'utf-8', |
| 163 | + maxBuffer: 50 * 1024 * 1024, |
| 164 | + }); |
| 165 | + |
| 166 | + if (showResult.status === 0) { |
| 167 | + const destPath = path.join(scanInputDir, filePath); |
| 168 | + // Create parent directories for nested paths (e.g. src/lib/foo.json) |
| 169 | + fs.mkdirSync(path.dirname(destPath), { recursive: true }); |
| 170 | + fs.writeFileSync(destPath, showResult.stdout); |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | + // Run OWASP dependency-check. |
| 175 | + // dependency-check may be a shell wrapper script, so shell: true is required. |
| 176 | + // Exit code 0 = no findings, 1 = findings present, other values = tool error. |
| 177 | + step.log('Running OWASP dependency-check...'); |
| 178 | + const scanResult = await runCommand( |
| 179 | + tempDir, |
| 180 | + 'dependency-check', |
| 181 | + [ |
| 182 | + '--noupdate', |
| 183 | + '--project', |
| 184 | + 'git-proxy-dependency-check', |
| 185 | + '--scan', |
| 186 | + scanInputDir, |
| 187 | + '--format', |
| 188 | + 'JSON', |
| 189 | + '--out', |
| 190 | + tempDir, |
| 191 | + ], |
| 192 | + { shell: true }, |
| 193 | + ); |
| 194 | + |
| 195 | + if (scanResult.exitCode !== 0 && scanResult.exitCode !== 1) { |
| 196 | + step.setError( |
| 197 | + 'dependency-check failed to run. Ensure it is installed and in PATH, and that ' + |
| 198 | + '`dependency-check --updateonly` has been run at least once.', |
| 199 | + ); |
| 200 | + action.addStep(step); |
| 201 | + return action; |
| 202 | + } |
| 203 | + |
| 204 | + const reportPath = path.join(tempDir, 'dependency-check-report.json'); |
| 205 | + const report = JSON.parse(fs.readFileSync(reportPath, 'utf-8')); |
| 206 | + |
| 207 | + const findings = report.dependencies.flatMap((dep) => |
| 208 | + (dep.vulnerabilities ?? []) |
| 209 | + .filter((vuln) => { |
| 210 | + const level = SEVERITY_LEVELS[vuln.severity?.toLowerCase()] ?? 0; |
| 211 | + return level >= minLevel; |
| 212 | + }) |
| 213 | + .map((vuln) => ({ |
| 214 | + file: dep.fileName, |
| 215 | + cve: vuln.name, |
| 216 | + severity: vuln.severity?.toUpperCase() ?? 'UNKNOWN', |
| 217 | + description: (vuln.description ?? '').substring(0, 150), |
| 218 | + })), |
| 219 | + ); |
| 220 | + |
| 221 | + if (findings.length > 0) { |
| 222 | + const details = findings |
| 223 | + .map((f) => ` [${f.severity}] ${f.cve} in ${f.file}: ${f.description}`) |
| 224 | + .join('\n'); |
| 225 | + step.setAsyncBlock( |
| 226 | + `Dependency vulnerabilities found at or above ${thresholdKey.toUpperCase()} severity:\n${details}`, |
| 227 | + ); |
| 228 | + } else { |
| 229 | + step.log( |
| 230 | + `No dependency vulnerabilities at or above ${thresholdKey.toUpperCase()} severity found.`, |
| 231 | + ); |
| 232 | + } |
| 233 | + } catch (error) { |
| 234 | + step.setError(`Dependency check encountered an unexpected error: ${error.message}`); |
| 235 | + } finally { |
| 236 | + // Clean up the temp directory regardless of outcome |
| 237 | + fs.rm(tempDir, { recursive: true, force: true }, () => {}); |
| 238 | + action.addStep(step); |
| 239 | + } |
| 240 | + |
| 241 | + return action; |
| 242 | + }); |
| 243 | + } |
| 244 | +} |
| 245 | + |
| 246 | +export default new CheckDependencyVulnPlugin(); |
0 commit comments