Skip to content

Commit 5a0759b

Browse files
committed
feat: secret scanning with gitleaks, better proxy mode output
Secret scanning (GitleaksRunner): - fix: run gitleaks in /tmp to prevent scanning the server working directory - fix: remove --no-git flag so gitleaks detects config from the diff context - enrich findings with file path and file-relative line numbers parsed from diff hunk headers - include commit hash (short) and redacted match snippet in rejection messages - multi-line toMessage() format respects 80-char git sideband width limit CommitInspectionService: - fix new-branch push diff base: instead of diffing against an empty tree (which causes false positives from existing repo files), walk commits not reachable from any existing branch and diff from the oldest new commit parent GitProxyFilter (proxy mode): - replace GitSmartHttpTools.sendError with proper sideband output: CH_PROGRESS (0x02) packets per line -> printed as "remote: <line>" by git client CH_ERROR (0x03) packet -> triggers die() on client side pkt-line flush (0000) to signal end-of-stream - rejection messages now display multi-line "remote:" output matching GitHub/GitLab UX PushContext: - add getStepContent(stepName) helper to look up step output by name Test reorganization: - split monolithic test-push-fail.sh / test-proxy-fail.sh into per-scenario scripts under test/ (author, diff, message, secrets for both push and proxy modes)
1 parent 1987547 commit 5a0759b

24 files changed

Lines changed: 1580 additions & 508 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CLAUDE.md

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Java rewrite of [FINOS git-proxy](https://github.com/finos/git-proxy) (Node.js).
88
|--------|---------|
99
| `jgit-proxy-core` | Shared library: filter chain, JGit hooks, push store, provider model, approval abstraction |
1010
| `jgit-proxy-server` | Standalone proxy-only server (`GitProxyJettyApplication`) — no dashboard, no Spring |
11-
| `jgit-proxy-api` | Dashboard + REST API (`GitProxyWithDashboardApplication`) — Spring MVC, approval UI, depends on `jgit-proxy-server` |
11+
| `jgit-proxy-dashboard` | Dashboard + REST API (`GitProxyWithDashboardApplication`) — Spring MVC, approval UI, depends on `jgit-proxy-server` |
1212

1313
## Architecture
1414

@@ -40,8 +40,8 @@ Unit tests live under each module's `src/test/`. E2e tests are in `jgit-proxy-se
4040
./gradlew :jgit-proxy-server:stop
4141

4242
# Proxy + dashboard + REST API (http://localhost:8080/):
43-
./gradlew :jgit-proxy-api:run &
44-
./gradlew :jgit-proxy-api:stop
43+
./gradlew :jgit-proxy-dashboard:run &
44+
./gradlew :jgit-proxy-dashboard:stop
4545

4646
# Logs: jgit-proxy-server/logs/application.log (DEBUG for org.finos.gitproxy)
4747
# Default DB: h2-file — persisted to jgit-proxy-server/.data/gitproxy.mv.db

gitleaks

Lines changed: 513 additions & 0 deletions
Large diffs are not rendered by default.

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/CommitInspectionService.java

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,13 @@ public static List<DiffEntry> getDiff(Repository repository, String fromCommit,
110110
CanonicalTreeParser oldTreeParser = new CanonicalTreeParser();
111111
CanonicalTreeParser newTreeParser = new CanonicalTreeParser();
112112

113-
// For new-branch pushes fromCommit is the all-zeros null object, which does not
114-
// exist in the repository. Guard before calling resolve() — JGit throws
115-
// MissingObjectException rather than returning null for the zero SHA.
116-
ObjectId oldId = isNullCommit(fromCommit) ? null : repository.resolve(fromCommit + "^{tree}");
113+
// For new-branch pushes fromCommit is the all-zeros null object. Rather than
114+
// diffing against an empty tree (which would show the entire repo snapshot and
115+
// trigger false-positive secret findings from existing files like package-lock.json),
116+
// find the parent of the oldest new commit so only the genuinely new content is scanned.
117+
ObjectId oldId = isNullCommit(fromCommit)
118+
? findNewBranchBase(repository, toCommit)
119+
: repository.resolve(fromCommit + "^{tree}");
117120
ObjectId newId = repository.resolve(toCommit + "^{tree}");
118121

119122
if (oldId != null) {
@@ -171,6 +174,41 @@ private static boolean isNullCommit(String commitId) {
171174
return commitId == null || commitId.matches("^0+$");
172175
}
173176

177+
/**
178+
* For a new-branch push, find the tree that should be used as the diff base so that only the genuinely new content
179+
* is scanned. Walks commits reachable from {@code toCommit} that are NOT reachable from any existing
180+
* {@code refs/heads/*} branch tip, then returns the tree of the oldest such commit's first parent. Returns
181+
* {@code null} if the oldest new commit is a root commit (base is the empty tree).
182+
*/
183+
private static ObjectId findNewBranchBase(Repository repository, String toCommit) throws IOException {
184+
ObjectId toId = repository.resolve(toCommit);
185+
if (toId == null) return null;
186+
187+
try (Git git = new Git(repository)) {
188+
var logCmd = git.log().add(toId);
189+
Collection<Ref> existingRefs = repository.getRefDatabase().getRefsByPrefix("refs/heads/");
190+
for (Ref ref : existingRefs) {
191+
if (ref.getObjectId() != null) logCmd.not(ref.getObjectId());
192+
}
193+
194+
List<RevCommit> newCommits = new ArrayList<>();
195+
for (RevCommit c : logCmd.call()) {
196+
newCommits.add(c);
197+
}
198+
199+
if (newCommits.isEmpty()) return null;
200+
201+
// logCmd returns newest-first; last entry is the oldest new commit
202+
RevCommit oldest = newCommits.get(newCommits.size() - 1);
203+
if (oldest.getParentCount() == 0) return null; // root commit — empty tree is correct
204+
205+
String parentSha = oldest.getParent(0).getName();
206+
return repository.resolve(parentSha + "^{tree}");
207+
} catch (GitAPIException e) {
208+
throw new IOException("Failed to find new-branch base commit", e);
209+
}
210+
}
211+
174212
/**
175213
* Convert a JGit RevCommit to our Commit model.
176214
*

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/DiffScanningHook.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import java.util.regex.Pattern;
1212
import lombok.RequiredArgsConstructor;
1313
import lombok.extern.slf4j.Slf4j;
14-
import org.eclipse.jgit.lib.Repository;
1514
import org.eclipse.jgit.transport.PreReceiveHook;
1615
import org.eclipse.jgit.transport.ReceiveCommand;
1716
import org.eclipse.jgit.transport.ReceivePack;
@@ -21,8 +20,7 @@
2120

2221
/**
2322
* Pre-receive hook that scans the diff content of incoming pushes for blocked literals and patterns. Runs after
24-
* {@link DiffGenerationHook} so the diff is already available for review, but independently generates its own diff for
25-
* scanning.
23+
* {@link DiffGenerationHook} and re-uses the diff already stored in {@link PushContext} rather than regenerating it.
2624
*
2725
* <p>Only added lines (those prefixed with {@code +} in the unified diff) are scanned — deletions and context lines are
2826
* ignored. Violations are reported via sideband and recorded in the shared {@link ValidationContext}.
@@ -39,7 +37,17 @@ public class DiffScanningHook implements PreReceiveHook {
3937
public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
4038
rp.sendMessage(CYAN + "[git-proxy] " + KEY.emoji() + " Scanning diff content..." + RESET);
4139

42-
Repository repo = rp.getRepository();
40+
// Re-use the diff already generated by DiffGenerationHook
41+
String diff = pushContext
42+
.getStepContent(DiffGenerationHook.STEP_NAME_PUSH_DIFF)
43+
.orElse(null);
44+
45+
if (diff == null || diff.isBlank()) {
46+
rp.sendMessage(YELLOW + "[git-proxy] " + WARNING.emoji() + " No diff available for scanning — skipping"
47+
+ RESET);
48+
return;
49+
}
50+
4351
List<String> logs = new ArrayList<>();
4452
boolean anyFailed = false;
4553

@@ -49,9 +57,6 @@ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
4957
}
5058

5159
try {
52-
String diff = CommitInspectionService.getFormattedDiff(
53-
repo, cmd.getOldId().name(), cmd.getNewId().name());
54-
5560
if (diff.isEmpty()) {
5661
rp.sendMessage(GREEN + "[git-proxy] " + HEAVY_CHECK_MARK.emoji() + " " + cmd.getRefName()
5762
+ " — empty diff, nothing to scan" + RESET);

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/GitClient.java

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,11 @@ public static String formatForOperation(
187187
}
188188

189189
private static String formatTitle(String content) {
190-
return "\n\n\t" + content + "\n";
190+
return "\n\n" + content + "\n";
191191
}
192192

193193
private static String formatMessage(String content) {
194-
// Indent each line with a tab to align with the title
195-
String indented = content.lines().map(line -> "\t" + line).collect(java.util.stream.Collectors.joining("\n"));
196-
return "\n" + indented + "\n";
194+
return "\n" + content + "\n";
197195
}
198196

199197
private static String convertSymbolsToPlain(String content) {
@@ -253,9 +251,10 @@ public static String buildValidationSummary(List<PushStep> steps) {
253251
" " + sym(SymbolCodes.HEAVY_CHECK_MARK) + " " + step.getStepName() + " — passed"))
254252
.append("\n");
255253
} else if (step.getStatus() == StepStatus.FAIL) {
254+
String detail = step.getErrorMessage() != null ? step.getErrorMessage() : "failed";
256255
sb.append(color(
257256
AnsiColor.RED,
258-
" " + sym(SymbolCodes.CROSS_MARK) + " " + step.getStepName() + " — failed"))
257+
" " + sym(SymbolCodes.CROSS_MARK) + " " + step.getStepName() + " — " + detail))
259258
.append("\n");
260259
}
261260
}

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/GitleaksRunner.java

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ public Optional<List<Finding>> scan(String diff, CommitConfig.SecretScanningConf
9797

9898
ProcessBuilder pb = new ProcessBuilder(cmd);
9999
pb.redirectErrorStream(false);
100+
// Run in /tmp so gitleaks doesn't walk the server's working directory
101+
pb.directory(reportFile.getParent().toFile());
100102
Process process = pb.start();
101103

102104
// Write diff to stdin in a daemon thread; closing stdin signals EOF to gitleaks
@@ -127,6 +129,7 @@ public Optional<List<Finding>> scan(String diff, CommitConfig.SecretScanningConf
127129
return Optional.of(Collections.emptyList());
128130
} else if (exitCode == 1) {
129131
List<Finding> findings = readFindings(reportFile);
132+
enrichFindings(findings, diff);
130133
log.debug("gitleaks: {} finding(s)", findings.size());
131134
return Optional.of(findings);
132135
} else {
@@ -340,7 +343,6 @@ private static List<String> buildCommand(
340343
List<String> cmd = new ArrayList<>();
341344
cmd.add(binaryPath.toString());
342345
cmd.add("detect");
343-
cmd.add("--no-git");
344346
cmd.add("--pipe");
345347
cmd.add("--report-format");
346348
cmd.add("json");
@@ -375,6 +377,66 @@ private static List<Finding> readFindings(Path reportFile) {
375377
}
376378
}
377379

380+
// -------------------------------------------------------------------------
381+
// Diff-line enrichment
382+
// -------------------------------------------------------------------------
383+
384+
/**
385+
* Gitleaks {@code --pipe} mode reports {@code StartLine} as the 0-indexed line number within the raw diff text and
386+
* leaves {@code File} empty. This method parses the diff to map each finding back to the actual file path and the
387+
* file-relative (1-indexed) line number.
388+
*/
389+
private static void enrichFindings(List<Finding> findings, String diff) {
390+
if (findings.isEmpty()) return;
391+
392+
String[] lines = diff.split("\n", -1);
393+
String[] fileAtLine = new String[lines.length];
394+
int[] fileLineAtLine = new int[lines.length];
395+
396+
String currentFile = null;
397+
int nextFileLine = 0;
398+
399+
for (int i = 0; i < lines.length; i++) {
400+
String line = lines[i];
401+
if (line.startsWith("+++ b/")) {
402+
currentFile = line.substring("+++ b/".length());
403+
} else if (line.startsWith("+++ /dev/null")) {
404+
currentFile = null;
405+
} else if (line.startsWith("@@")) {
406+
nextFileLine = parseHunkNewStart(line);
407+
} else if (line.startsWith("+")) {
408+
fileAtLine[i] = currentFile;
409+
fileLineAtLine[i] = nextFileLine;
410+
nextFileLine++;
411+
} else if (line.startsWith(" ")) {
412+
nextFileLine++;
413+
}
414+
}
415+
416+
for (Finding f : findings) {
417+
int idx = f.getStartLine(); // 0-indexed line in diff
418+
if (idx >= 0 && idx < lines.length && fileAtLine[idx] != null) {
419+
f.setFile(fileAtLine[idx]);
420+
f.setStartLine(fileLineAtLine[idx]);
421+
}
422+
}
423+
}
424+
425+
private static int parseHunkNewStart(String hunkHeader) {
426+
// "@@ -old_start[,old_count] +new_start[,new_count] @@"
427+
int plusIdx = hunkHeader.indexOf(" +");
428+
if (plusIdx < 0) return 1;
429+
int start = plusIdx + 2;
430+
int end = hunkHeader.indexOf(',', start);
431+
if (end < 0) end = hunkHeader.indexOf(' ', start);
432+
if (end < 0) return 1;
433+
try {
434+
return Integer.parseInt(hunkHeader.substring(start, end));
435+
} catch (NumberFormatException e) {
436+
return 1;
437+
}
438+
}
439+
378440
// -------------------------------------------------------------------------
379441
// Finding model
380442
// -------------------------------------------------------------------------
@@ -396,28 +458,42 @@ public static class Finding {
396458
@JsonProperty("StartLine")
397459
private int startLine;
398460

461+
@JsonProperty("Commit")
462+
private String commit;
463+
399464
/**
400465
* The matched text with the actual secret value partially redacted by gitleaks (the {@code Match} field) rather
401466
* than the raw {@code Secret} field, to avoid logging plaintext secrets.
402467
*/
403468
@JsonProperty("Match")
404469
private String match;
405470

406-
/** Human-readable summary suitable for a push error message. */
471+
/**
472+
* Multi-line summary suitable for a push error message. Each line is kept short to fit the git sideband 80-char
473+
* width limit (git prefixes each newline with "remote: ").
474+
*/
407475
public String toMessage() {
408-
StringBuilder sb = new StringBuilder("Secret detected");
409-
if (ruleId != null && !ruleId.isBlank()) {
410-
sb.append(" [").append(ruleId).append("]");
411-
}
412-
if (description != null && !description.isBlank()) {
413-
sb.append(": ").append(description);
414-
}
476+
StringBuilder sb = new StringBuilder();
477+
478+
// Line 1: rule ID + location
479+
sb.append("[").append(ruleId != null ? ruleId : "unknown").append("]");
415480
if (file != null && !file.isBlank()) {
416-
sb.append(" in ").append(file);
481+
sb.append(" ").append(file);
417482
if (startLine > 0) {
418483
sb.append(":").append(startLine);
419484
}
420485
}
486+
487+
// Line 2: commit hash (short)
488+
if (commit != null && commit.length() >= 7) {
489+
sb.append("\n commit: ").append(commit, 0, 7);
490+
}
491+
492+
// Line 3: redacted match snippet
493+
if (match != null && !match.isBlank()) {
494+
sb.append("\n match: ").append(match);
495+
}
496+
421497
return sb.toString();
422498
}
423499
}

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/PushContext.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.ArrayList;
44
import java.util.Collections;
55
import java.util.List;
6+
import java.util.Optional;
67
import org.finos.gitproxy.db.model.PushStep;
78

89
/**
@@ -22,4 +23,12 @@ public void addStep(PushStep step) {
2223
public List<PushStep> getSteps() {
2324
return Collections.unmodifiableList(steps);
2425
}
26+
27+
/** Returns the content of the first step matching {@code stepName}, if present. */
28+
public Optional<String> getStepContent(String stepName) {
29+
return steps.stream()
30+
.filter(s -> stepName.equals(s.getStepName()))
31+
.findFirst()
32+
.map(PushStep::getContent);
33+
}
2534
}

jgit-proxy-core/src/main/java/org/finos/gitproxy/git/SecretScanningHook.java

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import java.util.Optional;
1212
import lombok.RequiredArgsConstructor;
1313
import lombok.extern.slf4j.Slf4j;
14-
import org.eclipse.jgit.lib.Repository;
1514
import org.eclipse.jgit.transport.PreReceiveHook;
1615
import org.eclipse.jgit.transport.ReceiveCommand;
1716
import org.eclipse.jgit.transport.ReceivePack;
@@ -21,10 +20,9 @@
2120

2221
/**
2322
* Pre-receive hook that scans the diff of incoming pushes for secrets using gitleaks. Runs after
24-
* {@link DiffGenerationHook} in the store-and-forward pipeline.
23+
* {@link DiffGenerationHook} and re-uses the diff already stored in {@link PushContext} rather than regenerating it.
2524
*
26-
* <p>Each ref's diff is generated independently using {@link CommitInspectionService#getFormattedDiff} and piped to
27-
* gitleaks. Findings are reported via the sideband channel and recorded in the shared {@link ValidationContext}.
25+
* <p>Findings are reported via the sideband channel and recorded in the shared {@link ValidationContext}.
2826
*
2927
* <p>If the gitleaks binary is unavailable or execution fails, the hook logs a warning and continues (fail-open).
3028
* Pushes are never blocked because the scanner is misconfigured.
@@ -55,7 +53,17 @@ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
5553

5654
rp.sendMessage(color(CYAN, "[git-proxy] " + sym(KEY) + " Scanning for secrets..."));
5755

58-
Repository repo = rp.getRepository();
56+
// Re-use the diff already generated by DiffGenerationHook
57+
String diff = pushContext
58+
.getStepContent(DiffGenerationHook.STEP_NAME_PUSH_DIFF)
59+
.orElse(null);
60+
61+
if (diff == null || diff.isBlank()) {
62+
rp.sendMessage(color(
63+
YELLOW, "[git-proxy] " + sym(WARNING) + " No diff available for secret scanning — skipping"));
64+
return;
65+
}
66+
5967
List<String> logs = new ArrayList<>();
6068
boolean anyFailed = false;
6169

@@ -65,18 +73,6 @@ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
6573
}
6674

6775
try {
68-
String diff = CommitInspectionService.getFormattedDiff(
69-
repo, cmd.getOldId().name(), cmd.getNewId().name());
70-
71-
if (diff.isBlank()) {
72-
rp.sendMessage(color(
73-
GREEN,
74-
"[git-proxy] " + sym(HEAVY_CHECK_MARK) + " " + cmd.getRefName()
75-
+ " — empty diff, nothing to scan"));
76-
logs.add("SKIP: " + cmd.getRefName() + " — empty diff");
77-
continue;
78-
}
79-
8076
Optional<List<GitleaksRunner.Finding>> result = runner.scan(diff, config);
8177

8278
if (result.isEmpty()) {
@@ -112,12 +108,10 @@ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
112108
}
113109
}
114110

115-
if (!anyFailed) {
116-
pushContext.addStep(PushStep.builder()
117-
.stepName(STEP_NAME)
118-
.status(StepStatus.PASS)
119-
.logs(logs)
120-
.build());
121-
}
111+
pushContext.addStep(PushStep.builder()
112+
.stepName(STEP_NAME)
113+
.status(anyFailed ? StepStatus.FAIL : StepStatus.PASS)
114+
.logs(logs)
115+
.build());
122116
}
123117
}

0 commit comments

Comments
 (0)