Skip to content

Commit a18b20a

Browse files
committed
feat: finos#799 update and revision
1 parent 6981427 commit a18b20a

3 files changed

Lines changed: 285 additions & 1 deletion

File tree

plugins/git-proxy-plugin-samples/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,43 @@ These plugins are maintained by the core GitProxy team. As a future roadmap item
1010
certain features of GitProxy by simply removing the dependency from a deployed version of the application.
1111

1212
- `git-proxy-plugin-samples`: "hello world" examples of the GitProxy plugin system
13+
- `check-dependency-vulnerabilities`: blocks pushes that introduce dependencies with known CVEs
14+
15+
### check-dependency-vulnerabilities
16+
17+
Scans dependency files changed in a push (e.g. `package.json`, `pom.xml`, `requirements.txt`) against
18+
the [OWASP National Vulnerability Database](https://jeremylong.github.io/DependencyCheck/analyzers/index.html)
19+
using the [dependency-check](https://owasp.org/www-project-dependency-check/) CLI tool.
20+
21+
**Prerequisites**
22+
23+
- The `dependency-check` CLI must be installed and available in `PATH`.
24+
- Run `dependency-check --updateonly` at least once after installation to populate the NVD database.
25+
Repeat periodically to keep vulnerability data current (the plugin uses `--noupdate` on each scan
26+
to avoid the 20-30 minute refresh overhead).
27+
28+
**Configuration**
29+
30+
Set the `DEPENDENCY_VULN_THRESHOLD` environment variable to control which severity levels trigger a block.
31+
Pushes containing vulnerabilities at or above the threshold will be held for human review.
32+
33+
| Value | Blocks |
34+
| ---------- | --------------------------- |
35+
| `CRITICAL` | Critical only |
36+
| `HIGH` | High and Critical (default) |
37+
| `MEDIUM` | Medium, High, and Critical |
38+
| `LOW` | Low and above |
39+
| `INFO` | All findings |
40+
41+
**Enabling the plugin**
42+
43+
Add the plugin path to the `plugins` array in your `proxy.config.json`:
44+
45+
```json
46+
{
47+
"plugins": ["./plugins/git-proxy-plugin-samples/checkDependencyVuln.js"]
48+
}
49+
```
1350

1451
## Contributing
1552

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
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();

plugins/git-proxy-plugin-samples/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"type": "module",
1111
"exports": {
1212
".": "./index.js",
13-
"./example": "./example.cjs"
13+
"./example": "./example.cjs",
14+
"./check-dependency-vulnerabilities": "./checkDependencyVuln.js"
1415
},
1516
"dependencies": {
1617
"express": "^5.2.1"

0 commit comments

Comments
 (0)