Skip to content

Commit 94ab3cf

Browse files
authored
Merge pull request #1010 from 06kellyjac/gitleaks
feat: integrate gitleaks
2 parents a059202 + 8a08115 commit 94ab3cf

File tree

4 files changed

+202
-2
lines changed

4 files changed

+202
-2
lines changed

src/proxy/chain.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ const pushActionChain: ((req: any, action: Action) => Promise<Action>)[] = [
1414
proc.push.writePack,
1515
proc.push.preReceive,
1616
proc.push.getDiff,
17+
// run before clear remote
18+
proc.push.gitleaks,
1719
proc.push.clearBareClone,
1820
proc.push.scanDiff,
1921
proc.push.blockForAuth,
2022
];
2123

22-
const pullActionChain: ((req: any, action: Action) => Promise<Action>)[] = [proc.push.checkRepoInAuthorisedList];
24+
const pullActionChain: ((req: any, action: Action) => Promise<Action>)[] = [
25+
proc.push.checkRepoInAuthorisedList,
26+
];
2327

2428
let pluginsInserted = false;
2529

@@ -57,7 +61,9 @@ export const executeChain = async (req: any, res: any): Promise<Action> => {
5761
*/
5862
let chainPluginLoader: PluginLoader;
5963

60-
const getChain = async (action: Action): Promise<((req: any, action: Action) => Promise<Action>)[]> => {
64+
const getChain = async (
65+
action: Action,
66+
): Promise<((req: any, action: Action) => Promise<Action>)[]> => {
6167
if (chainPluginLoader === undefined) {
6268
console.error(
6369
'Plugin loader was not initialized! This is an application error. Please report it to the GitProxy maintainers. Skipping plugins...',
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { Action, Step } from '../../actions';
2+
import { getAPIs } from '../../../config';
3+
import { spawn } from 'node:child_process';
4+
import fs from 'node:fs/promises';
5+
import { PathLike } from 'node:fs';
6+
7+
const EXIT_CODE = 99;
8+
9+
function runCommand(
10+
cwd: string,
11+
command: string,
12+
args: readonly string[] = [],
13+
): Promise<{
14+
exitCode: number | null;
15+
stdout: string;
16+
stderr: string;
17+
}> {
18+
return new Promise((resolve, reject) => {
19+
const child = spawn(command, args, { cwd, shell: true });
20+
21+
let stdout = '';
22+
let stderr = '';
23+
24+
child.stdout.on('data', (data) => {
25+
stdout += data?.toString() ?? '';
26+
});
27+
28+
child.stderr.on('data', (data) => {
29+
stderr += data?.toString() ?? '';
30+
});
31+
32+
child.on('close', (exitCode) => {
33+
resolve({ exitCode, stdout, stderr });
34+
});
35+
36+
child.on('error', (err) => {
37+
reject(err);
38+
});
39+
});
40+
}
41+
42+
type ConfigOptions = {
43+
enabled: boolean;
44+
ignoreGitleaksAllow: boolean;
45+
noColor: boolean;
46+
configPath: string | undefined;
47+
};
48+
49+
const DEFAULT_CONFIG: ConfigOptions = {
50+
// adding gitleaks into main git-proxy for now as default off
51+
// in the future will likely be moved to a plugin where it'll be default on
52+
enabled: false,
53+
ignoreGitleaksAllow: true,
54+
noColor: false,
55+
configPath: undefined,
56+
};
57+
58+
function isRecord(value: unknown): value is Record<string, unknown> {
59+
return typeof value === 'object' && value !== null;
60+
}
61+
62+
async function fileIsReadable(path: PathLike): Promise<boolean> {
63+
try {
64+
if (!(await fs.stat(path)).isFile()) {
65+
return false;
66+
}
67+
await fs.access(path, fs.constants.R_OK);
68+
return true;
69+
} catch (e) {
70+
return false;
71+
}
72+
}
73+
74+
const getPluginConfig = async (): Promise<ConfigOptions> => {
75+
const userConfig = getAPIs();
76+
if (typeof userConfig !== 'object') {
77+
return DEFAULT_CONFIG;
78+
}
79+
if (!Object.hasOwn(userConfig, 'gitleaks')) {
80+
return DEFAULT_CONFIG;
81+
}
82+
const gitleaksConfig = userConfig.gitleaks;
83+
if (!isRecord(gitleaksConfig)) {
84+
return DEFAULT_CONFIG;
85+
}
86+
87+
let configPath: string | undefined = undefined;
88+
if (typeof gitleaksConfig.configPath === 'string') {
89+
const userConfigPath = gitleaksConfig.configPath.trim();
90+
if (userConfigPath.length > 0 && (await fileIsReadable(userConfigPath))) {
91+
configPath = userConfigPath;
92+
} else {
93+
console.error('could not read file at the config path provided, will not be fed to gitleaks');
94+
throw new Error("could not check user's config path");
95+
}
96+
}
97+
98+
// TODO: integrate zod
99+
return {
100+
enabled:
101+
typeof gitleaksConfig.enabled === 'boolean' ? gitleaksConfig.enabled : DEFAULT_CONFIG.enabled,
102+
ignoreGitleaksAllow:
103+
typeof gitleaksConfig.ignoreGitleaksAllow === 'boolean'
104+
? gitleaksConfig.ignoreGitleaksAllow
105+
: DEFAULT_CONFIG.ignoreGitleaksAllow,
106+
noColor:
107+
typeof gitleaksConfig.noColor === 'boolean' ? gitleaksConfig.noColor : DEFAULT_CONFIG.noColor,
108+
configPath,
109+
};
110+
};
111+
112+
const exec = async (req: any, action: Action): Promise<Action> => {
113+
const step = new Step('gitleaks');
114+
115+
let config: ConfigOptions | undefined = undefined;
116+
try {
117+
config = await getPluginConfig();
118+
} catch (e) {
119+
console.error('failed to get gitleaks config, please fix the error:', e);
120+
action.error = true;
121+
step.setError('failed setup gitleaks, please contact an administrator\n');
122+
action.addStep(step);
123+
return action;
124+
}
125+
126+
const { commitFrom, commitTo } = action;
127+
const workingDir = `${action.proxyGitPath}/${action.repoName}`;
128+
console.log(`Scanning range with gitleaks: ${commitFrom}:${commitTo}`, workingDir);
129+
130+
try {
131+
const gitRootCommit = await runCommand(workingDir, 'git', [
132+
'rev-list',
133+
'--max-parents=0',
134+
'HEAD',
135+
]);
136+
if (gitRootCommit.exitCode !== 0) {
137+
throw new Error('failed to run git');
138+
}
139+
const rootCommit = gitRootCommit.stdout.trim();
140+
141+
const gitleaksArgs = [
142+
`--exit-code=${EXIT_CODE}`,
143+
'--platform=none',
144+
config.configPath ? `--config=${config.configPath}` : undefined, // allow for custom config
145+
config.ignoreGitleaksAllow ? '--ignore-gitleaks-allow' : undefined, // force scanning for security
146+
'--no-banner', // reduce git-proxy error output
147+
config.noColor ? '--no-color' : undefined, // colour output should appear properly in the console
148+
'--redact', // avoid printing the contents
149+
'--verbose',
150+
'git',
151+
// not using --no-merges to be sure we're scanning the diff
152+
// only add ^ if the commitFrom isn't the repo's rootCommit
153+
`--log-opts='--first-parent ${rootCommit === commitFrom ? rootCommit : `${commitFrom}^`}..${commitTo}'`,
154+
].filter((v) => typeof v === 'string');
155+
const gitleaks = await runCommand(workingDir, 'gitleaks', gitleaksArgs);
156+
157+
if (gitleaks.exitCode !== 0) {
158+
// any failure
159+
step.error = true;
160+
if (gitleaks.exitCode !== EXIT_CODE) {
161+
step.setError('failed to run gitleaks, please contact an administrator\n');
162+
} else {
163+
// exit code matched our gitleaks findings exit code
164+
// newline prefix to avoid tab indent at the start
165+
step.setError('\n' + gitleaks.stdout + gitleaks.stderr);
166+
}
167+
} else {
168+
console.log('succeded');
169+
console.log(gitleaks.stderr);
170+
}
171+
} catch (e) {
172+
action.error = true;
173+
step.setError('failed to spawn gitleaks, please contact an administrator\n');
174+
action.addStep(step);
175+
return action;
176+
}
177+
178+
action.addStep(step);
179+
return action;
180+
};
181+
182+
exec.displayName = 'gitleaks.exec';
183+
184+
export { exec };

src/proxy/processors/push-action/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { exec as audit } from './audit';
55
import { exec as pullRemote } from './pullRemote';
66
import { exec as writePack } from './writePack';
77
import { exec as getDiff } from './getDiff';
8+
import { exec as gitleaks } from './gitleaks';
89
import { exec as scanDiff } from './scanDiff';
910
import { exec as blockForAuth } from './blockForAuth';
1011
import { exec as checkIfWaitingAuth } from './checkIfWaitingAuth';
@@ -21,6 +22,7 @@ export {
2122
pullRemote,
2223
writePack,
2324
getDiff,
25+
gitleaks,
2426
scanDiff,
2527
blockForAuth,
2628
checkIfWaitingAuth,

test/chain.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const mockPushProcessors = {
2727
writePack: sinon.stub(),
2828
preReceive: sinon.stub(),
2929
getDiff: sinon.stub(),
30+
gitleaks: sinon.stub(),
3031
clearBareClone: sinon.stub(),
3132
scanDiff: sinon.stub(),
3233
blockForAuth: sinon.stub(),
@@ -42,6 +43,7 @@ mockPushProcessors.pullRemote.displayName = 'pullRemote';
4243
mockPushProcessors.writePack.displayName = 'writePack';
4344
mockPushProcessors.preReceive.displayName = 'preReceive';
4445
mockPushProcessors.getDiff.displayName = 'getDiff';
46+
mockPushProcessors.gitleaks.displayName = 'gitleaks';
4547
mockPushProcessors.clearBareClone.displayName = 'clearBareClone';
4648
mockPushProcessors.scanDiff.displayName = 'scanDiff';
4749
mockPushProcessors.blockForAuth.displayName = 'blockForAuth';
@@ -179,6 +181,7 @@ describe('proxy chain', function () {
179181
mockPushProcessors.writePack.resolves(continuingAction);
180182
mockPushProcessors.preReceive.resolves(continuingAction);
181183
mockPushProcessors.getDiff.resolves(continuingAction);
184+
mockPushProcessors.gitleaks.resolves(continuingAction);
182185
mockPushProcessors.clearBareClone.resolves(continuingAction);
183186
mockPushProcessors.scanDiff.resolves(continuingAction);
184187
mockPushProcessors.blockForAuth.resolves(continuingAction);
@@ -196,6 +199,7 @@ describe('proxy chain', function () {
196199
expect(mockPushProcessors.writePack.called).to.be.true;
197200
expect(mockPushProcessors.preReceive.called).to.be.true;
198201
expect(mockPushProcessors.getDiff.called).to.be.true;
202+
expect(mockPushProcessors.gitleaks.called).to.be.true;
199203
expect(mockPushProcessors.clearBareClone.called).to.be.true;
200204
expect(mockPushProcessors.scanDiff.called).to.be.true;
201205
expect(mockPushProcessors.blockForAuth.called).to.be.true;
@@ -276,6 +280,7 @@ describe('proxy chain', function () {
276280
});
277281

278282
mockPushProcessors.getDiff.resolves(action);
283+
mockPushProcessors.gitleaks.resolves(action);
279284
mockPushProcessors.clearBareClone.resolves(action);
280285
mockPushProcessors.scanDiff.resolves(action);
281286
mockPushProcessors.blockForAuth.resolves(action);
@@ -322,6 +327,7 @@ describe('proxy chain', function () {
322327
});
323328

324329
mockPushProcessors.getDiff.resolves(action);
330+
mockPushProcessors.gitleaks.resolves(action);
325331
mockPushProcessors.clearBareClone.resolves(action);
326332
mockPushProcessors.scanDiff.resolves(action);
327333
mockPushProcessors.blockForAuth.resolves(action);
@@ -368,6 +374,7 @@ describe('proxy chain', function () {
368374
});
369375

370376
mockPushProcessors.getDiff.resolves(action);
377+
mockPushProcessors.gitleaks.resolves(action);
371378
mockPushProcessors.clearBareClone.resolves(action);
372379
mockPushProcessors.scanDiff.resolves(action);
373380
mockPushProcessors.blockForAuth.resolves(action);
@@ -413,6 +420,7 @@ describe('proxy chain', function () {
413420
});
414421

415422
mockPushProcessors.getDiff.resolves(action);
423+
mockPushProcessors.gitleaks.resolves(action);
416424
mockPushProcessors.clearBareClone.resolves(action);
417425
mockPushProcessors.scanDiff.resolves(action);
418426
mockPushProcessors.blockForAuth.resolves(action);

0 commit comments

Comments
 (0)