Skip to content

Commit 805d585

Browse files
authored
fix: secret scanning broken in Docker — missing git binary + exit code ambiguity (#202)
## Summary - **Root cause of #189:** The Docker runtime image was missing the \`git\` binary. \`gitleaks git\` mode shells out to \`git\` to traverse commit history — without it, gitleaks exits 1 with an empty report. The proxy misread exit 1 as "findings present, read report", got an empty list, and silently passed the push. - **Secondary fix:** Use \`--exit-code 2\` so gitleaks findings produce exit 2, making runtime errors (exit 1) unambiguous from clean scans (exit 0) and findings (exit 2). - **Fail-closed:** When \`secret-scan: enabled: true\` and the scanner errors or is unavailable, the push is now blocked with a clear message instead of silently passing. - **Smoke tests upgraded:** Fake truncated PEM keys replaced with real keys from \`openssl\`/\`ssh-keygen\` (with fallback detection). Added PKCS#8 (\`BEGIN PRIVATE KEY\`) alongside PKCS#1 (\`BEGIN RSA PRIVATE KEY\`). - **CI:** New \`docker-smoke-test\` job builds the image, seeds Gitea, and runs \`push-fail-secrets.sh\` + \`proxy-fail-secrets.sh\` end-to-end against the real Docker distribution. - **Descriptive error on blocked \`/info/refs\`:** Previously returned a bare 403 with no body. ## Test plan - [ ] CI passes — \`docker-smoke-test\` job blocks all secret-containing pushes - [ ] \`GitleaksRunnerTest\` — 4 tests covering \`scanGit\` and \`scan\` modes - [ ] \`SecretScanningFilterTest#scannerUnavailable_failClosed\` — scanner error produces FAIL step - [ ] \`UrlRuleFilterTest\` — descriptive \`sendError\` + \`recordFetch\` on blocked \`/info/refs\` - [ ] Local: \`source test/gitea/env.sh && bash test/push-fail-secrets.sh && bash test/proxy-fail-secrets.sh\` closes #189 closes #200
2 parents 8bc1194 + 67360c4 commit 805d585

15 files changed

Lines changed: 505 additions & 153 deletions

File tree

.github/workflows/ci.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,69 @@ jobs:
5353
path: "**/build/reports/jacoco/"
5454
retention-days: 14
5555

56+
docker-smoke-test:
57+
name: CI / Docker Smoke Test (${{ matrix.db }})
58+
runs-on: ubuntu-latest
59+
if: >
60+
github.ref == 'refs/heads/main' ||
61+
github.event_name == 'pull_request'
62+
strategy:
63+
matrix:
64+
db: [default, postgres, mongo]
65+
# auth: [ldap, oidc] # Uncomment when auth smoke tests are added
66+
fail-fast: false
67+
68+
steps:
69+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
70+
71+
- name: Build and start stack
72+
env:
73+
DB: ${{ matrix.db }}
74+
run: |
75+
if [[ "$DB" != "default" ]]; then
76+
bash compose.sh --db "$DB" -- up -d --build
77+
else
78+
bash compose.sh -- up -d --build
79+
fi
80+
timeout-minutes: 10
81+
82+
- name: Wait for proxy health
83+
run: |
84+
for i in $(seq 1 30); do
85+
if curl -sf http://localhost:8080/api/health > /dev/null; then
86+
echo "Proxy healthy after ${i}s"
87+
break
88+
fi
89+
sleep 1
90+
done
91+
curl -sf http://localhost:8080/api/health
92+
93+
- name: Seed Gitea
94+
run: bash docker/gitea-setup.sh
95+
96+
- name: Run proxy smoke tests
97+
run: |
98+
source test/gitea/tokens.env
99+
export GIT_PASSWORD="${GITEA_TESTUSER_TOKEN}"
100+
export GIT_USERNAME="me"
101+
export GIT_REPO="gitea/test-owner/test-repo.git"
102+
export GIT_AUTHOR_NAME="test-user"
103+
export GIT_EMAIL="testuser@example.com"
104+
# Proxy mode only — store-and-forward passes queue for approval and hang
105+
bash test/proxy-pass.sh
106+
bash test/proxy-fail-secrets.sh
107+
108+
- name: Dump proxy logs on failure
109+
if: failure()
110+
env:
111+
DB: ${{ matrix.db }}
112+
run: |
113+
if [[ "$DB" != "default" ]]; then
114+
bash compose.sh --db "$DB" -- logs git-proxy-java
115+
else
116+
bash compose.sh -- logs git-proxy-java
117+
fi
118+
56119
dependency-submission:
57120
name: CI / Dependency Submission
58121
runs-on: ubuntu-latest

Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# syntax=docker/dockerfile:1
1+
# syntax=docker/dockerfile:1@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
22

33
# ── Build stage ──────────────────────────────────────────────────────────────
44
FROM docker.io/eclipse-temurin:21-jdk@sha256:e58e492628c1428ceb838afc1a1b8762673d5eaa09296f560c363daea0fdcf3b AS builder
@@ -51,6 +51,8 @@ FROM docker.io/eclipse-temurin:21-jre@sha256:ff65ff0d43c73d2b675eb4b758665a5cb48
5151

5252
WORKDIR /app
5353

54+
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
55+
5456
# Copy the built distribution
5557
COPY --from=builder \
5658
/workspace/git-proxy-java-dashboard/build/install/git-proxy-java-dashboard/ /app/

compose.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,13 @@ if [[ -n "$DB" && "$DB" != "postgres" && "$DB" != "mongo" ]]; then
4848
exit 1
4949
fi
5050

51-
if command -v podman &>/dev/null; then
51+
if command -v docker &>/dev/null && docker info &>/dev/null 2>&1; then
52+
COMPOSE="docker compose"
53+
elif command -v podman &>/dev/null && podman info &>/dev/null 2>&1; then
5254
COMPOSE="podman compose"
5355
else
54-
COMPOSE="docker compose"
56+
echo "No working container runtime found (tried docker, podman)" >&2
57+
exit 1
5558
fi
5659

5760
ARGS=(-f docker-compose.yml)

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
* <li>System PATH - falls back to a {@code gitleaks} binary already installed on the host/container
4141
* </ol>
4242
*
43-
* <p>If no binary is found, scanning is skipped and an empty {@link Optional} is returned (fail-open). Pushes are never
44-
* blocked due to scanner unavailability.
43+
* <p>If no binary is found or the scan errors, an empty {@link Optional} is returned. Whether the push is blocked is
44+
* determined by the caller — callers with {@code secret-scan: enabled: true} block the push (fail-closed).
4545
*
4646
* <p>The bundled binary extracted from the JAR is deleted on JVM shutdown. Binaries downloaded to {@code installDir}
4747
* persist across restarts.
@@ -58,6 +58,9 @@ public class GitleaksRunner {
5858
/** Classpath resource name for the pre-bundled gitleaks binary (opt-in, not shipped by default). */
5959
private static final String BUNDLED_BINARY_RESOURCE = "gitleaks";
6060

61+
/** Exit code gitleaks returns when findings are present (distinct from error exit codes). */
62+
private static final int FINDINGS_EXIT_CODE = 2;
63+
6164
private static final String DEFAULT_INSTALL_DIR =
6265
System.getProperty("user.home") + "/.cache/git-proxy-java/gitleaks";
6366

@@ -132,13 +135,13 @@ public Optional<List<Finding>> scan(String diff, SecretScanConfig config) {
132135
if (exitCode == 0) {
133136
log.debug("gitleaks: no findings");
134137
return Optional.of(Collections.emptyList());
135-
} else if (exitCode == 1) {
138+
} else if (exitCode == FINDINGS_EXIT_CODE) {
136139
List<Finding> findings = readFindings(reportFile);
137140
enrichFindings(findings, diff);
138141
log.debug("gitleaks: {} finding(s)", findings.size());
139142
return Optional.of(findings);
140143
} else {
141-
log.warn("gitleaks exited with code {} - secret scanning skipped (fail-open)", exitCode);
144+
log.warn("gitleaks exited with code {} - treat as scanner error", exitCode);
142145
return Optional.empty();
143146
}
144147

@@ -214,12 +217,12 @@ public Optional<List<Finding>> scanGit(Path repoDir, String commitFrom, String c
214217
if (exitCode == 0) {
215218
log.debug("gitleaks git: no findings");
216219
return Optional.of(Collections.emptyList());
217-
} else if (exitCode == 1) {
220+
} else if (exitCode == FINDINGS_EXIT_CODE) {
218221
List<Finding> findings = readFindings(reportFile);
219222
log.debug("gitleaks git: {} finding(s)", findings.size());
220223
return Optional.of(findings);
221224
} else {
222-
log.warn("gitleaks git exited with code {} - secret scanning skipped (fail-open)", exitCode);
225+
log.warn("gitleaks git exited with code {} - treat as scanner error", exitCode);
223226
return Optional.empty();
224227
}
225228

@@ -429,6 +432,8 @@ private static List<String> buildCommand(Path binaryPath, Path reportFile, Path
429432
cmd.add("json");
430433
cmd.add("--report-path");
431434
cmd.add(reportFile.toString());
435+
cmd.add("--exit-code");
436+
cmd.add(String.valueOf(FINDINGS_EXIT_CODE));
432437

433438
if (configFilePath != null) {
434439
cmd.add("--config");
@@ -449,6 +454,8 @@ private static List<String> buildGitCommand(Path binaryPath, String logOpts, Pat
449454
cmd.add(reportFile.toString());
450455
cmd.add("--no-banner");
451456
cmd.add("--redact");
457+
cmd.add("--exit-code");
458+
cmd.add(String.valueOf(FINDINGS_EXIT_CODE));
452459

453460
if (configFilePath != null) {
454461
cmd.add("--config");

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
* to limit scanning to commits not already reachable from any existing ref; branch updates use
2626
* {@code commitFrom..commitTo}.
2727
*
28-
* <p>If the scanner is unavailable the hook continues (fail-open), recording a SKIPPED step.
28+
* <p>If the scanner is unavailable and secret scanning is enabled, the push is blocked (fail-closed). If scanning is
29+
* disabled the hook records a SKIPPED step.
2930
*/
3031
@Slf4j
3132
@RequiredArgsConstructor
@@ -61,7 +62,6 @@ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
6162
Optional<List<GitleaksRunner.Finding>> result = runner.scanGit(repoDir, commitFrom, commitTo, config);
6263

6364
if (result.isEmpty()) {
64-
// Fail-open: scanner unavailable or errored - GitleaksRunner already logged the detail
6565
scannerUnavailable = true;
6666
continue;
6767
}
@@ -72,13 +72,19 @@ public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
7272
}
7373
}
7474

75-
if (scannerUnavailable && allViolations.isEmpty()) {
76-
pushContext.addStep(PushStep.builder()
77-
.stepName(STEP_NAME)
78-
.stepOrder(ORDER)
79-
.status(StepStatus.SKIPPED)
80-
.build());
81-
return;
75+
if (scannerUnavailable) {
76+
if (config.isEnabled()) {
77+
String msg = "Secret scanning failed — scanner error or unavailable. "
78+
+ "Push blocked because secret-scan is enabled. Check server logs for details.";
79+
allViolations.add(new Violation(msg, msg, sym(CROSS_MARK) + " " + msg));
80+
} else {
81+
pushContext.addStep(PushStep.builder()
82+
.stepName(STEP_NAME)
83+
.stepOrder(ORDER)
84+
.status(StepStatus.SKIPPED)
85+
.build());
86+
return;
87+
}
8288
}
8389

8490
if (allViolations.isEmpty()) {

git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/SecretScanningFilter.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
* correctly because gitleaks has full file-path context when operating in git mode.
2626
*
2727
* <p>Runs at order 340, after {@link ScanDiffFilter} (order 300), within the content filters range (200-399).
28+
*
29+
* <p>If the scanner errors or is unavailable when scanning is enabled, the push is blocked (fail-closed) with a clear
30+
* error message. Check server logs for the underlying cause.
2831
*/
2932
@Slf4j
3033
public class SecretScanningFilter extends AbstractGitProxyFilter {
@@ -95,7 +98,10 @@ public void doHttpFilter(HttpServletRequest request, HttpServletResponse respons
9598
runner.scanGit(repo.getDirectory().toPath(), commitFrom, commitTo, config);
9699

97100
if (result.isEmpty()) {
98-
// Fail-open: scanner unavailable - GitleaksRunner already logged the detail
101+
String msg = "Secret scanning failed — scanner error or unavailable. "
102+
+ "Push blocked because secret-scan is enabled. Check server logs for details.";
103+
log.error("Secret scanner returned no result — blocking push (fail-closed)");
104+
recordIssue(request, msg, sym(CROSS_MARK) + " " + msg);
99105
return;
100106
}
101107

git-proxy-java-core/src/main/java/org/finos/gitproxy/servlet/filter/UrlRuleAggregateFilter.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,18 @@ private void applyInfoRefsRules(HttpServletRequest request, HttpServletResponse
162162
log.debug("Blocking /info/refs — matched deny rule: {}", d.ruleId());
163163
if (effectiveOp == HttpOperation.FETCH && fetchStore != null) recordFetch(request, false);
164164
setResult(request, GitRequestDetails.GitResult.REJECTED, "Repository blocked by deny rule");
165-
response.sendError(provider.getBlockedInfoRefsStatus());
165+
response.sendError(
166+
provider.getBlockedInfoRefsStatus(),
167+
"Repository access denied: this repository has been explicitly blocked by an administrator.");
166168
}
167169
case UrlRuleEvaluator.Result.NotAllowed n -> {
168170
log.debug("Blocking /info/refs — no rule matched");
169171
if (effectiveOp == HttpOperation.FETCH && fetchStore != null) recordFetch(request, false);
170172
setResult(request, GitRequestDetails.GitResult.REJECTED, "Repository not in allow rules");
171-
response.sendError(provider.getBlockedInfoRefsStatus());
173+
response.sendError(
174+
provider.getBlockedInfoRefsStatus(),
175+
"Repository access denied: this repository is not in the allow list."
176+
+ " Contact an administrator to add it.");
172177
}
173178
case UrlRuleEvaluator.Result.Allowed a -> {
174179
/* pass through — request is permitted */

0 commit comments

Comments
 (0)