diff --git a/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/GenericWeakCredentialDetectorBootstrapModule.java b/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/GenericWeakCredentialDetectorBootstrapModule.java index b27c7693c..cf798b3b2 100644 --- a/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/GenericWeakCredentialDetectorBootstrapModule.java +++ b/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/GenericWeakCredentialDetectorBootstrapModule.java @@ -39,6 +39,7 @@ import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.grafana.GrafanaCredentialTester; import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.hydra.HydraCredentialTester; import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.jenkins.JenkinsCredentialTester; +import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.kubeflow.KubeflowCredentialTester; import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.mlflow.MlFlowCredentialTester; import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.mysql.MysqlCredentialTester; import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.hive.HiveCredentialTester; @@ -68,6 +69,7 @@ protected void configurePlugin() { Multibinder credentialTesterBinder = Multibinder.newSetBinder(binder(), CredentialTester.class); + credentialTesterBinder.addBinding().to(KubeflowCredentialTester.class); credentialTesterBinder.addBinding().to(JenkinsCredentialTester.class); credentialTesterBinder.addBinding().to(MlFlowCredentialTester.class); credentialTesterBinder.addBinding().to(MysqlCredentialTester.class); diff --git a/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/kubeflow/KubeflowCredentialTester.java b/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/kubeflow/KubeflowCredentialTester.java new file mode 100644 index 000000000..6b0e08dc0 --- /dev/null +++ b/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/kubeflow/KubeflowCredentialTester.java @@ -0,0 +1,199 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.kubeflow; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.tsunami.common.data.NetworkServiceUtils.buildWebApplicationRootUrl; +import static com.google.tsunami.common.data.NetworkServiceUtils.isWebService; +import static com.google.tsunami.common.net.http.HttpRequest.get; +import static com.google.tsunami.common.net.http.HttpRequest.post; + +import com.google.common.collect.ImmutableList; +import com.google.common.flogger.GoogleLogger; +import com.google.gson.JsonObject; +import com.google.protobuf.ByteString; +import com.google.tsunami.common.data.NetworkEndpointUtils; +import com.google.tsunami.common.data.NetworkServiceUtils; +import com.google.tsunami.common.net.http.HttpClient; +import com.google.tsunami.common.net.http.HttpHeaders; +import com.google.tsunami.common.net.http.HttpResponse; +import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.provider.TestCredential; +import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.tester.CredentialTester; +import com.google.tsunami.proto.NetworkService; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import javax.inject.Inject; + +/** Credential tester specifically for kubeflow. */ +public final class KubeflowCredentialTester extends CredentialTester { + private static final GoogleLogger logger = GoogleLogger.forEnclosingClass(); + + private final HttpClient httpClient; + + @Inject + KubeflowCredentialTester(HttpClient httpClient) { + this.httpClient = checkNotNull(httpClient).modify().setFollowRedirects(false).build(); + } + + @Override + public String name() { + return "KubeflowCredentialTester"; + } + + @Override + public String description() { + return "Kubeflow credential tester."; + } + + @Override + public boolean canAccept(NetworkService networkService) { + return NetworkServiceUtils.getWebServiceName(networkService).equals("kubeflow"); + } + + @Override + public boolean batched() { + return true; + } + + @Override + public ImmutableList testValidCredentials( + NetworkService networkService, List credentials) { + // Always return 1st weak credential to gracefully handle no auth configured case, where we + // return empty credential instead of all the weak credentials + logger.atWarning().log("====== ======== "); + return credentials.stream() + .filter(cred -> isKubeflowAccessible(networkService, cred)) + .findFirst() + .map(ImmutableList::of) + .orElseGet(ImmutableList::of); + } + + private boolean isKubeflowAccessible(NetworkService networkService, TestCredential credential) { + // logger.atWarning().log("======================= '%s'", credential.username()); + final String rootUri = buildWebApplicationRootUrl(networkService); + try { + HttpResponse rsp = + httpClient.send( + get(rootUri + "oauth2/start?rd=%2F").withEmptyHeaders().build(), networkService); + if (rsp.headers().get("set-cookie").isEmpty() || rsp.headers().get("location").isEmpty()) { + return false; + } + String oauth2ProxyKubeflowCsrf = rsp.headers().get("set-cookie").get(); + HttpHeaders.Builder headers = + HttpHeaders.builder().addHeader("Cookie", oauth2ProxyKubeflowCsrf); + rsp = + httpClient.send( + get(rootUri + rsp.headers().get("location").get().substring(1)) + .setHeaders(headers.build()) + .build(), + networkService); + if (rsp.headers().get("location").isEmpty()) { + return false; + } + rsp = + httpClient.send( + get(rootUri + rsp.headers().get("location").get().substring(1)) + .setHeaders(headers.build()) + .build(), + networkService); + if (rsp.headers().get("location").isEmpty()) { + return false; + } + rsp = + httpClient.send( + get(rootUri + rsp.headers().get("location").get().substring(1)) + .setHeaders(headers.build()) + .build(), + networkService); + if (rsp.bodyString().isEmpty()) { + return false; + } + String action = null; + Document doc = Jsoup.parse(rsp.bodyString().get()); + for (Element anchor : doc.getElementsByTag("form")) { + action = anchor.attr("action"); + } + if (Objects.isNull(action)) { + return false; + } + rsp = + httpClient.send( + post(rootUri + action.substring(1)) + .setHeaders( + headers + .addHeader("Content-Type", "application/x-www-form-urlencoded") + .build()) + .setRequestBody( + ByteString.copyFromUtf8( + String.format( + "login=%s&password=%s", + URLEncoder.encode(credential.username(), StandardCharsets.UTF_8), + URLEncoder.encode( + credential.password().get(), StandardCharsets.UTF_8)))) + .build(), + networkService); + if (rsp.headers().get("location").isEmpty()) { + return false; + } + rsp = + httpClient.send( + get(rootUri + rsp.headers().get("location").get().substring(1)) + .setHeaders(headers.build()) + .build(), + networkService); + if (rsp.headers().get("set-cookie").isEmpty() || rsp.headers().get("location").isEmpty()) { + return false; + } + String authCookie = ""; + ImmutableList setCookieHeaders = rsp.headers().getAll("set-cookie"); + for (String setCookieHeader : setCookieHeaders) { + if (setCookieHeader.startsWith("oauth2_proxy_kubeflow=")) { + authCookie = setCookieHeader; + } + } + rsp = + httpClient.send( + get(rootUri + "api/dashboard-links") + .setHeaders( + HttpHeaders.builder() + .addHeader("Cookie", authCookie) + .addHeader("Accept", "application/json") + .build()) + .build(), + networkService); + if (rsp.bodyJson().isEmpty() || !rsp.status().isSuccess()) { + return false; + } + JsonObject bodyJsonObj = rsp.bodyJson().get().getAsJsonObject(); + if (bodyJsonObj.has("menuLinks") + || bodyJsonObj.has("documentationItems") + || bodyJsonObj.has("quickLinks")) { + return true; + } + } catch (RuntimeException | IOException e) { + logger.atWarning().withCause(e).log("Failed to send HTTP request to '%s'", rootUri); + return false; + } + return false; + } +} diff --git a/google/detectors/credentials/generic_weak_credential_detector/src/main/resources/detectors/credentials/genericweakcredentialdetector/data/service_default_credentials.textproto b/google/detectors/credentials/generic_weak_credential_detector/src/main/resources/detectors/credentials/genericweakcredentialdetector/data/service_default_credentials.textproto index 653b4e5ae..3a9263f34 100644 --- a/google/detectors/credentials/generic_weak_credential_detector/src/main/resources/detectors/credentials/genericweakcredentialdetector/data/service_default_credentials.textproto +++ b/google/detectors/credentials/generic_weak_credential_detector/src/main/resources/detectors/credentials/genericweakcredentialdetector/data/service_default_credentials.textproto @@ -83,3 +83,9 @@ service_default_credentials { default_usernames: "default" default_passwords: "" } + +service_default_credentials { + service_name: "kubeflow" + default_usernames: "user@example.com" + default_passwords: "12341234" +} diff --git a/google/detectors/credentials/generic_weak_credential_detector/src/test/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/kubeflow/KubeflowCredentialTesterTest.java b/google/detectors/credentials/generic_weak_credential_detector/src/test/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/kubeflow/KubeflowCredentialTesterTest.java new file mode 100644 index 000000000..69c51ec81 --- /dev/null +++ b/google/detectors/credentials/generic_weak_credential_detector/src/test/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/kubeflow/KubeflowCredentialTesterTest.java @@ -0,0 +1,188 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.kubeflow; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.tsunami.common.data.NetworkEndpointUtils.forHostnameAndPort; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Guice; +import com.google.tsunami.common.net.http.HttpClientModule; +import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.provider.TestCredential; +import com.google.tsunami.proto.NetworkService; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; +import javax.inject.Inject; +import com.google.tsunami.proto.ServiceContext; +import com.google.tsunami.proto.Software; +import com.google.tsunami.proto.WebServiceContext; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.Before; +import org.junit.Rule; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.junit.Test; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Tests for {@link KubeflowCredentialTesterTest}. */ +@RunWith(JUnit4.class) +public class KubeflowCredentialTesterTest { + @Rule public MockitoRule rule = MockitoJUnit.rule(); + @Inject private KubeflowCredentialTester tester; + private MockWebServer mockWebServer; + private static final TestCredential WEAK_CRED_1 = + TestCredential.create("user@example.com", Optional.of("12341234")); + private static final TestCredential WRONG_CRED_1 = + TestCredential.create("wrong", Optional.of("wrong")); + private static final ServiceContext.Builder kubeServiceContext = + ServiceContext.newBuilder() + .setWebServiceContext( + WebServiceContext.newBuilder().setSoftware(Software.newBuilder().setName("jenkins"))); + + @Before + public void setup() { + mockWebServer = new MockWebServer(); + Guice.createInjector(new HttpClientModule.Builder().build()).injectMembers(this); + } + + @Test + public void detect_weakCredentialsExist_returnsFirstWeakCredentials() throws Exception { + startMockWebServer(); + NetworkService targetNetworkService = + NetworkService.newBuilder() + .setNetworkEndpoint( + forHostnameAndPort(mockWebServer.getHostName(), mockWebServer.getPort())) + .setServiceName("http") + .setServiceContext(kubeServiceContext) + .setSoftware(Software.newBuilder().setName("http")) + .build(); + + assertThat(tester.testValidCredentials(targetNetworkService, ImmutableList.of(WEAK_CRED_1))) + .containsExactly(WEAK_CRED_1); + } + + @Test + public void detect_noWeakCredentials_returnsNoCredentials() throws Exception { + startMockWebServer(); + NetworkService targetNetworkService = + NetworkService.newBuilder() + .setNetworkEndpoint( + forHostnameAndPort(mockWebServer.getHostName(), mockWebServer.getPort())) + .setServiceName("http") + .setServiceContext(kubeServiceContext) + .setSoftware(Software.newBuilder().setName("http")) + .build(); + assertThat(tester.testValidCredentials(targetNetworkService, ImmutableList.of(WRONG_CRED_1))) + .isEmpty(); + } + + private void startMockWebServer() throws IOException { + final Dispatcher dispatcher = + new Dispatcher() { + final MockResponse unauthorizedResponse = + new MockResponse() + .setResponseCode(401) + .setBody( + "{\"detail\":[\"AuthorizationException\"," + + "\"Authentication error: invalid username or password\"]}"); + + @Override + public MockResponse dispatch(RecordedRequest request) { + if (request.getPath().startsWith("/oauth2/start") + && Objects.equals(request.getMethod(), "GET")) { + return new MockResponse() + .setResponseCode(200) + .setHeader("set-cookie", "oauth2ProxyKubeflowCsrf") + .setHeader("location", "/location1"); + } else if (request.getPath().startsWith("/location1") + && Objects.equals(request.getMethod(), "GET")) { + return new MockResponse().setResponseCode(301).setHeader("location", "/location2"); + } else if (request.getPath().startsWith("/location2") + && Objects.equals(request.getMethod(), "GET")) { + return new MockResponse().setResponseCode(301).setHeader("location", "/location3"); + } else if (request.getPath().startsWith("/location3") + && Objects.equals(request.getMethod(), "GET")) { + return new MockResponse() + .setResponseCode(200) + .setBody( + "\n" + + "\n" + + " \n" + + "
\n" + + "

Log in to Your Account

\n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + "
\n" + + "
\n" + + " \n" + + ""); + } else if (request.getPath().startsWith("/dex/auth/local/login?back=&state=") + && Objects.equals(request.getMethod(), "POST") + && request + .getBody() + .toString() + .contains("login=user%40example.com&password=12341234")) { + return new MockResponse().setResponseCode(200).setHeader("location", "/location5"); + } else if (request.getPath().startsWith("/location5") + && Objects.equals(request.getMethod(), "GET")) { + return new MockResponse() + .setResponseCode(200) + .setHeader("location", "/") + .setHeader("set-cookie", "oauth2_proxy_kubeflow=D1EtyeQnMFozaaaa;"); + } else if (request.getPath().startsWith("/api/dashboard-links") + && Objects.equals(request.getMethod(), "GET") + && request + .getHeader("Cookie") + .contains("oauth2_proxy_kubeflow=D1EtyeQnMFozaaaa;")) { + return new MockResponse() + .setResponseCode(200) + .setHeader("content-type", "application/json; charset=utf-8") + .setBody( + "{\n" + + " \"menuLinks\": [\n" + + " {\n" + + " \"type\": \"item\"\n" + + " }\n" + + " ],\n" + + " \"documentationItems\": [\n" + + " {\n" + + " \"text\": \"Kubeflow Website\"\n" + + " }\n" + + " ],\n" + + " \"quickLinks\": [\n" + + " {\n" + + " \"desc\": \"Pipelines\"" + + " }\n" + + " ]\n" + + "}"); + } else { + return unauthorizedResponse; + } + } + }; + mockWebServer.setDispatcher(dispatcher); + mockWebServer.start(); + mockWebServer.url("/"); + } +} diff --git a/google/fingerprinters/web/src/main/java/com/google/tsunami/plugins/fingerprinters/web/WebServiceFingerprinter.java b/google/fingerprinters/web/src/main/java/com/google/tsunami/plugins/fingerprinters/web/WebServiceFingerprinter.java index b713d30d3..4a9e9720a 100644 --- a/google/fingerprinters/web/src/main/java/com/google/tsunami/plugins/fingerprinters/web/WebServiceFingerprinter.java +++ b/google/fingerprinters/web/src/main/java/com/google/tsunami/plugins/fingerprinters/web/WebServiceFingerprinter.java @@ -280,6 +280,7 @@ private ImmutableSet detectSoftwareByCustomHeuristics( checkForMlflow(detectedSoftware, networkService, startingUrl); checkForZenMl(detectedSoftware, networkService, startingUrl); + checkForKubeflow(detectedSoftware, networkService, startingUrl); return ImmutableSet.copyOf(detectedSoftware); } @@ -372,4 +373,19 @@ private void checkForZenMl( logger.atWarning().withCause(e).log("Unable to query '%s'.", loginUrl); } } + + private void checkForKubeflow( + HashSet detectedSoftware, + NetworkService networkService, + String startingUrl) { + + logger.atInfo().log("probing Kubeflow login page and login api - custom fingerprint phase"); + + detectedSoftware.add( + DetectedSoftware.builder() + .setSoftwareIdentity(SoftwareIdentity.newBuilder().setSoftware("kubeflow").build()) + .setRootPath(startingUrl) + .setContentHashes(ImmutableMap.of()) + .build()); + } }