diff --git a/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java b/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java index 5bca4cf..fb0a5fc 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java +++ b/fleetd/src/main/java/dev/ltms/fleet/session/GitWorktrees.java @@ -110,6 +110,7 @@ public final class GitWorktrees implements Worktrees { @Override public String add(String repoRoot, String branch, String baseRef) { + reportRemoteUrlsWithUserInfo(repoRoot); String base = (baseRef == null || baseRef.isBlank()) ? "HEAD" : baseRef; String nonce = nonce(); Path root = resolveRoot(repoRoot); @@ -139,7 +140,7 @@ public final class GitWorktrees implements Worktrees { if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) { return; } - String origin = exec("git", "-C", repoRoot, "config", "--get", "remote.origin.url").trim(); + String origin = execRedacted("git", "-C", repoRoot, "config", "--get", "remote.origin.url").trim(); URI uri; try { uri = new URI(origin); @@ -155,6 +156,9 @@ public final class GitWorktrees implements Worktrees { throw new WorktreeException("origin URL has invalid HTTPS user info; cannot provision safely"); } String cleanOrigin = origin.substring(0, schemeEnd) + origin.substring(userInfoEnd + 1); + // Plain exec, not execRedacted, is correct here: this call WRITES cleanOrigin (already + // stripped of user-info above) rather than reading a URL back from stdout, so there is + // nothing secret left in either its argv or its stdout to redact. exec("git", "-C", repoRoot, "remote", "set-url", "origin", cleanOrigin); log.info("removed HTTPS user info from forge origin before provisioning worktree"); } @@ -164,7 +168,7 @@ public final class GitWorktrees implements Worktrees { if (exitCode("git", "-C", worktreePath, "remote", "get-url", "--all", "origin") != 0) { return; } - String origins = exec("git", "-C", worktreePath, "remote", "get-url", "--all", "origin"); + String origins = execRedacted("git", "-C", worktreePath, "remote", "get-url", "--all", "origin"); for (String origin : origins.split("\\R")) { try { URI uri = new URI(origin); @@ -177,6 +181,99 @@ public final class GitWorktrees implements Worktrees { } } + /** + * Report — never refuse — every remote whose fetch or push URL carries user-info outside SSH. + * A linked worktree shares its parent repository's git config, so a credential on ANY remote + * (not only {@code origin}) or in a {@code pushurl} is just as readable by a member as one on + * {@code origin}'s HTTPS fetch URL — the one case {@link #removeUserInfoFromHttpsOrigin} and + * {@link #requireCredentialFreeHttpsOrigin} already strip and refuse. This check is additive: it + * only logs a warning, it never mutates config and never refuses the provision. + * + *

A reporting-only check must never be able to abort a provision — PR #173 shipped one that + * ran unguarded at the top of {@link #add}, and every git call inside it can throw ({@link #exec} + * turns a non-zero exit or its 30-second timeout into a {@link WorktreeException}). Every git call + * here is therefore wrapped, and on failure only the exception's class is logged, never + * its message: the enumerating {@code git remote} call is not redacted, and its stderr is read + * from the very config that may hold the URL this check exists to find. + */ + private void reportRemoteUrlsWithUserInfo(String repoRoot) { + List remotes; + try { + remotes = exec("git", "-C", repoRoot, "remote").lines() + .map(String::trim) + .filter(r -> !r.isBlank()) + .toList(); + } catch (RuntimeException e) { + log.warn("could not enumerate remotes to check for credentialed URLs in {}: {}", + Path.of(repoRoot).toAbsolutePath().normalize(), e.getClass().getName()); + return; + } + for (String remote : remotes) { + reportOneRemoteUrlsWithUserInfo(repoRoot, remote); + } + } + + private void reportOneRemoteUrlsWithUserInfo(String repoRoot, String remote) { + boolean leaks = remoteUrlsLeakUserInfo(repoRoot, remote, false) + || remoteUrlsLeakUserInfo(repoRoot, remote, true); + if (leaks) { + log.warn("member worktree shares a remote URL containing user-info: remote={} repository={}; " + + "remove credentials from the repository's git config", + remote, Path.of(repoRoot).toAbsolutePath().normalize()); + } + } + + /** + * True when any resolved fetch (or, if {@code push}, push) URL for {@code remote} carries + * non-empty user-info outside SSH. Never throws — a git failure here is caught, logged (its + * class only, per the javadoc above), and treated as "nothing found", so it cannot abort or + * otherwise affect provisioning. Uses {@link #execRedacted} because the command's stdout is + * itself the URL this check exists to find. + */ + private boolean remoteUrlsLeakUserInfo(String repoRoot, String remote, boolean push) { + try { + String out = push + ? execRedacted("git", "-C", repoRoot, "remote", "get-url", "--push", "--all", remote) + : execRedacted("git", "-C", repoRoot, "remote", "get-url", "--all", remote); + return out.lines().anyMatch(url -> !url.isBlank() && urlLeaksUserInfo(url.trim())); + } catch (RuntimeException e) { + log.warn("could not read the {} URL for remote {} to check for credentials: {}", + push ? "push" : "fetch", remote, e.getClass().getName()); + return false; + } + } + + /** + * True when {@code rawUrl} parses as an absolute URI with a non-SSH-family scheme and non-empty + * user-info. An unparsable or scheme-less URL — including the ssh scp-like shorthand + * ({@code user@host:path}) — is not this check's concern and is treated as "no finding", the + * same way {@link #configureHttpsUrlRewriteForSshOrigin} leaves that shorthand untouched. + */ + private static boolean urlLeaksUserInfo(String rawUrl) { + URI uri; + try { + uri = new URI(rawUrl); + } catch (URISyntaxException e) { + return false; + } + String scheme = uri.getScheme(); + if (scheme == null || isSshLikeScheme(scheme)) { + return false; + } + String userInfo = uri.getUserInfo(); + return userInfo != null && !userInfo.isEmpty(); + } + + /** + * SSH-family schemes deliberately excluded from {@link #urlLeaksUserInfo}: there, the user part + * selects an account and authentication itself happens over SSH, so it is not a credential the + * way HTTPS/HTTP user-info is. + */ + private static boolean isSshLikeScheme(String scheme) { + return "ssh".equalsIgnoreCase(scheme) || "git+ssh".equalsIgnoreCase(scheme) + || "ssh+git".equalsIgnoreCase(scheme); + } + /** Configure a per-worktree helper that supplies a token from the member environment at call time. */ private void configureEnvironmentCredentialHelper(String repoRoot, String worktreePath) { exec("git", "-C", repoRoot, "config", "extensions.worktreeConfig", "true"); @@ -234,7 +331,7 @@ public final class GitWorktrees implements Worktrees { if (exitCode("git", "-C", repoRoot, "config", "--get", "remote.origin.url") != 0) { return; } - String origin = exec("git", "-C", repoRoot, "config", "--get", "remote.origin.url").trim(); + String origin = execRedacted("git", "-C", repoRoot, "config", "--get", "remote.origin.url").trim(); URI uri; try { uri = new URI(origin); @@ -603,6 +700,31 @@ public final class GitWorktrees implements Worktrees { /** Same as {@link #exec(String...)}, with extra environment variables set on the child process. */ private String exec(Map extraEnv, String... command) { + return exec(extraEnv, false, command); + } + + /** + * Same as {@link #exec(String...)}, for a command whose stdout may itself carry a credential + * (e.g. {@code git remote get-url}, whose output is a URL). Stdout is still returned normally on + * success — callers still get the URL to inspect — but it is suppressed from BOTH the timeout + * message and the non-zero-exit message, so a failing call here can never copy it into a + * {@link WorktreeException}, and from there into a caller's log. + */ + private String execRedacted(String... command) { + return exec(Map.of(), true, command); + } + + /** + * Shared implementation for {@link #exec(Map, String...)} and {@link #execRedacted(String...)}. + * {@code redactOutput} suppresses captured stdout from both failure messages below. + * + *

Package-private, not {@code private}: also a test seam, the same way the + * {@code afterWorktreeAdded} constructor parameter is. It lets a test drive the redaction + * guarantee directly — a synthetic failing command whose stdout carries a test marker passed + * through {@code extraEnv} rather than argv — without depending on finding a real git failure + * mode that happens to echo a URL onto stdout before exiting non-zero. + */ + String exec(Map extraEnv, boolean redactOutput, String... command) { String out; int code; Process p; @@ -624,7 +746,8 @@ public final class GitWorktrees implements Worktrees { try { if (!p.waitFor(30, TimeUnit.SECONDS)) { p.destroyForcibly(); - throw new WorktreeException("command timed out: " + String.join(" ", command) + "\n" + out); + throw new WorktreeException("command timed out: " + String.join(" ", command) + + (redactOutput || out.isBlank() ? "" : "\n" + out)); } code = p.exitValue(); } catch (InterruptedException e) { @@ -634,7 +757,7 @@ public final class GitWorktrees implements Worktrees { } if (code != 0) { throw new WorktreeException("exit " + code + " for: " + String.join(" ", command) - + (out.isBlank() ? "" : "\n" + out)); + + (redactOutput || out.isBlank() ? "" : "\n" + out)); } return out; } diff --git a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java index 1ddce93..74e99eb 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/session/GitWorktreesTest.java @@ -1,13 +1,23 @@ package dev.ltms.fleet.session; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -352,6 +362,151 @@ class GitWorktreesTest { assertEquals("worktree origin contains HTTPS user info; refusing provision", error.getMessage()); } + // ---- CB-189: broader remote-URL coverage — every remote, both fetch and push URLs, any + // non-SSH scheme. Reporting only, additive to the origin/https strip-and-refuse tests above. ---- + + private Logger reportingLogger; + private ListAppender reportingAppender; + + /** {@link GitWorktrees}'s own logger, captured fresh for each test so assertions never see a + * message left over from a previous test. */ + @BeforeEach + void attachReportingLogCapture() { + LoggerContext ctx = (LoggerContext) LoggerFactory.getILoggerFactory(); + reportingLogger = ctx.getLogger(GitWorktrees.class); + reportingLogger.setLevel(Level.WARN); + reportingAppender = new ListAppender<>(); + reportingAppender.setContext(ctx); + reportingAppender.start(); + reportingLogger.addAppender(reportingAppender); + } + + @AfterEach + void detachReportingLogCapture() { + reportingLogger.detachAppender(reportingAppender); + } + + private List capturedMessages() { + return reportingAppender.list.stream().map(ILoggingEvent::getFormattedMessage).toList(); + } + + /** Asserts {@code secret} appears in no captured message, and in no attached exception's + * message either — the constraint is that a credential must never reach a log, however it + * would have gotten there. */ + private void assertNoLeak(String secret) { + for (ILoggingEvent event : reportingAppender.list) { + assertFalse(event.getFormattedMessage().contains(secret), + "log message leaked a credential (" + secret + "): " + event.getFormattedMessage()); + IThrowableProxy thrown = event.getThrowableProxy(); + if (thrown != null && thrown.getMessage() != null) { + assertFalse(thrown.getMessage().contains(secret), + "logged exception leaked a credential (" + secret + "): " + thrown.getMessage()); + } + } + } + + /** Gap 1: only {@code origin} was ever inspected. A credential on any other remote's fetch URL + * must now be reported. */ + @Test + void aCredentialedUrlOnANonOriginRemoteIsReported(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git"); + git(repo, "remote", "add", "upstream", "https://leaky-upstream-token@git.ltms.dev/akb/kb.git"); + + new GitWorktrees(tmp.resolve("wts").toString()).add(repo.toString(), "cb-189-a", "HEAD"); + + List messages = capturedMessages(); + assertTrue(messages.stream().anyMatch(m -> m.contains("remote=upstream")), + "expected a report naming the leaking non-origin remote:\n" + messages); + assertNoLeak("leaky-upstream-token"); + assertNoLeak("https://leaky-upstream-token@git.ltms.dev/akb/kb.git"); + assertNoLeak("git.ltms.dev"); + } + + /** Gap 2: push URLs were never inspected. A credential visible only on {@code pushurl} — the + * fetch URL for the same remote stays clean — must now be reported. */ + @Test + void aCredentialedPushUrlIsReported(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git"); + git(repo, "remote", "add", "mirror", "https://git.ltms.dev/akb/mirror.git"); + git(repo, "remote", "set-url", "--push", "mirror", + "https://leaky-push-token@git.ltms.dev/akb/mirror.git"); + + new GitWorktrees(tmp.resolve("wts").toString()).add(repo.toString(), "cb-189-b", "HEAD"); + + List messages = capturedMessages(); + assertTrue(messages.stream().anyMatch(m -> m.contains("remote=mirror")), + "expected a report naming the remote with the leaking pushurl:\n" + messages); + assertNoLeak("leaky-push-token"); + assertNoLeak("https://leaky-push-token@git.ltms.dev/akb/mirror.git"); + assertNoLeak("git.ltms.dev"); + } + + /** Gap 3: only {@code https} was handled. A plain {@code http://user:pass@…} remote — worse + * than https, not better — must now be reported. */ + @Test + void anHttpUrlWithCredentialsIsReported(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + git(repo, "remote", "add", "origin", "https://git.ltms.dev/akb/kb.git"); + git(repo, "remote", "add", "insecure", "http://plainuser:plainpass@git.ltms.dev/akb/kb.git"); + + new GitWorktrees(tmp.resolve("wts").toString()).add(repo.toString(), "cb-189-c", "HEAD"); + + List messages = capturedMessages(); + assertTrue(messages.stream().anyMatch(m -> m.contains("remote=insecure")), + "expected a report for the credentialed plain-http remote:\n" + messages); + assertNoLeak("plainuser"); + assertNoLeak("plainpass"); + assertNoLeak("plainuser:plainpass"); + assertNoLeak("git.ltms.dev"); + } + + /** A normal {@code ssh://} remote and a credential-free {@code https://} remote must produce no + * report at all — the check must not cry wolf on ordinary, safe configuration. */ + @Test + void anSshRemoteAndACleanHttpsRemoteProduceNoReport(@TempDir Path tmp) throws Exception { + Path repo = initRepo(tmp.resolve("repo")); + git(repo, "remote", "add", "origin", "ssh://git@git.ltms.dev:2224/akb/kb.git"); + git(repo, "remote", "add", "clean", "https://git.ltms.dev/akb/kb.git"); + + new GitWorktrees(tmp.resolve("wts").toString()).add(repo.toString(), "cb-189-d", "HEAD"); + + assertTrue(reportingAppender.list.isEmpty(), + "expected no report for an ssh remote and a credential-free https remote, got:\n" + + capturedMessages()); + } + + /** + * CB-189 review fix. Even a FAILING command that read a credential onto its stdout must never + * let that value reach the thrown {@link WorktreeException}'s message — this is gap 4 from the + * CB-189 issue, and the reason {@link GitWorktrees#execRedacted} exists at all. Drives the + * shared {@code exec}/{@code execRedacted} seam directly (it is package-private for exactly this, + * the same way the {@code afterWorktreeAdded} constructor parameter is a test seam) with a + * synthetic, non-git command whose stdout carries a marker — passed through the environment, + * never through argv, so the marker cannot leak via the command line that IS always printed + * unconditionally in the exception message — and which exits non-zero. This isolates the + * redaction guarantee itself rather than depending on a specific git failure mode that happens to + * echo a URL onto stdout before failing: none of the git subcommands this class actually runs was + * found to have one (a corrupted config makes {@code git config --get} fail before it ever reads + * the target key, so its output never carries the URL either). The marker is generated per-test + * run and injected only by the test, never a real-looking credential, so even a failing assertion + * could not itself print a secret. + */ + @Test + void execRedactedNeverCopiesFailingCommandOutputIntoTheExceptionMessage(@TempDir Path tmp) { + String marker = "cb189-marker-" + System.nanoTime(); + GitWorktrees worktrees = new GitWorktrees(tmp.toString()); + + WorktreeException thrown = assertThrows(WorktreeException.class, () -> worktrees.exec( + Map.of("MARKER", marker), true, "sh", "-c", "echo \"$MARKER\"; exit 7")); + + assertNotNull(thrown.getMessage()); + assertFalse(thrown.getMessage().contains(marker), + "a failing command's captured stdout leaked into the exception message: " + + thrown.getMessage()); + } + /** Neutralizing must not look like work in progress, or a worker would commit it into its PR. */ @Test void theNeutralizedConfigIsNotAPendingLocalModification(@TempDir Path tmp) throws Exception {