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 7c46ce6b4..db865a24a 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 @@ -46,6 +46,8 @@ import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.rabbitmq.RabbitMQCredentialTester; import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.wordpress.WordpressCredentialTester; import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.rstudio.RStudioCredentialTester; +import com.google.tsunami.plugins.detectors.credentials.genericweakcredentialdetector.testers.zenml.ZenMlCredentialTester; + import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.Files; @@ -75,6 +77,7 @@ protected void configurePlugin() { credentialTesterBinder.addBinding().to(GrafanaCredentialTester.class); credentialTesterBinder.addBinding().to(RStudioCredentialTester.class); credentialTesterBinder.addBinding().to(RabbitMQCredentialTester.class); + credentialTesterBinder.addBinding().to(ZenMlCredentialTester.class); Multibinder credentialProviderBinder = Multibinder.newSetBinder(binder(), CredentialProvider.class); diff --git a/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/zenml/ZenMlCredentialTester.java b/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/zenml/ZenMlCredentialTester.java new file mode 100644 index 000000000..39f071d77 --- /dev/null +++ b/google/detectors/credentials/generic_weak_credential_detector/src/main/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/zenml/ZenMlCredentialTester.java @@ -0,0 +1,153 @@ +/* + * 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.zenml; + +import static com.google.common.base.Preconditions.checkNotNull; +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.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +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.common.net.http.HttpStatus; +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 java.io.IOException; +import java.util.List; +import javax.inject.Inject; + +/** Credential tester specifically for zenml. */ +public final class ZenMlCredentialTester extends CredentialTester { + private static final GoogleLogger logger = GoogleLogger.forEnclosingClass(); + private static final String ZENML_SERVICE = "zenml"; + + private final HttpClient httpClient; + + @Inject + ZenMlCredentialTester(HttpClient httpClient) { + this.httpClient = checkNotNull(httpClient); + } + + @Override + public String name() { + return "ZenMlCredentialTester"; + } + + @Override + public String description() { + return "ZenMl credential tester."; + } + + @Override + public boolean canAccept(NetworkService networkService) { + return NetworkServiceUtils.getWebServiceName(networkService).equals(ZENML_SERVICE); + } + + @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 + return credentials.stream() + .filter(cred -> isZenMlAccessible(networkService, cred)) + .findFirst() + .map(ImmutableList::of) + .orElseGet(ImmutableList::of); + } + + private boolean isZenMlAccessible(NetworkService networkService, TestCredential credential) { + var uriAuthority = NetworkEndpointUtils.toUriAuthority(networkService.getNetworkEndpoint()); + var loginApiUrl = String.format("http://%s/%s", uriAuthority, "api/v1/login"); + try { + HttpResponse apiLoginResponse = + httpClient.send( + post(loginApiUrl) + .setHeaders( + HttpHeaders.builder() + .addHeader("Content-Type", "application/x-www-form-urlencoded") + .build()) + .setRequestBody( + ByteString.copyFromUtf8( + String.format( + "username=%s&password=%s", + credential.username(), credential.password().orElse("")))) + .build()); + + if (apiLoginResponse.status() == HttpStatus.UNAUTHORIZED + && apiLoginResponse.bodyString().isPresent() + && apiLoginResponse + .bodyString() + .get() + .equals( + "{\"detail\":[\"AuthorizationException\"," + + "\"Authentication error: invalid username or password\"]}")) { + return false; + } + + if (apiLoginResponse.status() == HttpStatus.OK + && apiLoginResponse.bodyString().isPresent() + && bodyContainsSuccessfulAccessToken(apiLoginResponse.bodyString().get())) { + logger.atWarning().log("=============================================="); + return true; + } + + } catch (IOException e) { + logger.atWarning().withCause(e).log("Unable to query '%s'.", loginApiUrl); + return false; + } + return false; + } + + /** + * A successful authenticated request to the /api/v1/login endpoint returns a JSON with a root key + * like the following: {"access_token":"An Access + * Token","token_type":"bearer","expires_in":null,"refresh_token":null,"scope":null} + */ + private static boolean bodyContainsSuccessfulAccessToken(String responseBody) { + try { + JsonObject response = JsonParser.parseString(responseBody).getAsJsonObject(); + + if (response.has("access_token") + && response.has("token_type") + && response.has("refresh_token") + && response.has("scope") + && response.has("expires_in")) { + logger.atInfo().log("Successfully logged in as a zenml user"); + return true; + } else { + return false; + } + } catch (JsonSyntaxException e) { + logger.atWarning().withCause(e).log( + "An error occurred while parsing the json response: %s", responseBody); + 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 86293f939..653b4e5ae 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 @@ -77,3 +77,9 @@ service_default_credentials { default_usernames: "username" default_passwords: "password" } + +service_default_credentials { + service_name: "zenml" + default_usernames: "default" + default_passwords: "" +} diff --git a/google/detectors/credentials/generic_weak_credential_detector/src/test/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/zenml/ZenMlCredentialTesterTest.java b/google/detectors/credentials/generic_weak_credential_detector/src/test/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/zenml/ZenMlCredentialTesterTest.java new file mode 100644 index 000000000..9513d5c3b --- /dev/null +++ b/google/detectors/credentials/generic_weak_credential_detector/src/test/java/com/google/tsunami/plugins/detectors/credentials/genericweakcredentialdetector/testers/zenml/ZenMlCredentialTesterTest.java @@ -0,0 +1,196 @@ +/* + * 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.zenml; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.tsunami.common.data.NetworkEndpointUtils.forHostnameAndPort; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.inject.Guice; +import com.google.tsunami.common.net.db.ConnectionProviderInterface; +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.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.util.Objects; +import java.util.Optional; +import javax.inject.Inject; +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; +import org.mockito.Mock; + +/** Tests for {@link ZenMlCredentialTester}. */ +@RunWith(JUnit4.class) +public class ZenMlCredentialTesterTest { + @Rule public MockitoRule rule = MockitoJUnit.rule(); + @Mock private ConnectionProviderInterface mockConnectionProvider; + @Mock private Connection mockConnection; + @Inject private ZenMlCredentialTester tester; + private MockWebServer mockWebServer; + private static final TestCredential WEAK_CRED_1 = + TestCredential.create("default", Optional.of("")); + private static final TestCredential WRONG_CRED_1 = + TestCredential.create("wrong", Optional.of("wrong")); + + // the default username and password value for an insecure zenml instance + private static final String DEFAULT_USERNAME = "default"; + private static final String DEFAULT_PASSWORD = ""; + + @Before + public void setup() { + mockWebServer = new MockWebServer(); + Guice.createInjector(new HttpClientModule.Builder().build()).injectMembers(this); + } + + @Test + public void detect_weakCredentialsExists_returnsWeakCredentials() throws Exception { + startMockWebServer(); + NetworkService targetNetworkService = + NetworkService.newBuilder() + .setNetworkEndpoint( + forHostnameAndPort(mockWebServer.getHostName(), mockWebServer.getPort())) + .setServiceName("zenml") + .build(); + + assertThat(tester.testValidCredentials(targetNetworkService, ImmutableList.of(WEAK_CRED_1))) + .containsExactly(WEAK_CRED_1); + mockWebServer.shutdown(); + } + + @Test + public void detect_weakCredentialsExist_returnsFirstWeakCredentials() throws Exception { + startMockWebServer(); + NetworkService targetNetworkService = + NetworkService.newBuilder() + .setNetworkEndpoint( + forHostnameAndPort(mockWebServer.getHostName(), mockWebServer.getPort())) + .setServiceName("zenml") + .build(); + + assertThat(tester.testValidCredentials(targetNetworkService, ImmutableList.of(WEAK_CRED_1))) + .containsExactly(WEAK_CRED_1); + } + + @Test + public void detect_zenmlService_canAccept() throws Exception { + startMockWebServer(); + NetworkService targetNetworkService = + NetworkService.newBuilder() + .setNetworkEndpoint( + forHostnameAndPort(mockWebServer.getHostName(), mockWebServer.getPort())) + .setServiceName("zenml") + .build(); + + assertThat(tester.canAccept(targetNetworkService)).isTrue(); + } + + @Test + public void detect_weakCredentialsExistAndZenmlInForeignLanguage_returnsFirstWeakCredentials() + throws Exception { + startMockWebServer(); + NetworkService targetNetworkService = + NetworkService.newBuilder() + .setNetworkEndpoint( + forHostnameAndPort(mockWebServer.getHostName(), mockWebServer.getPort())) + .setServiceName("zenml") + .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("zenml") + .build(); + assertThat(tester.testValidCredentials(targetNetworkService, ImmutableList.of(WRONG_CRED_1))) + .isEmpty(); + } + + @Test + public void detect_nonZenmlService_skips() throws Exception { + when(mockConnectionProvider.getConnection(any(), any(), any())).thenReturn(mockConnection); + NetworkService targetNetworkService = + NetworkService.newBuilder() + .setNetworkEndpoint(forHostnameAndPort("example.com", 8080)) + .setServiceName("http") + .build(); + + assertThat(tester.testValidCredentials(targetNetworkService, ImmutableList.of(WEAK_CRED_1))) + .isEmpty(); + verifyNoInteractions(mockConnectionProvider); + } + + 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().matches("/login") && Objects.equals(request.getMethod(), "GET")) { + return new MockResponse() + .setResponseCode(200) + .setBody(" ZenML Dashboard "); + } + if (request.getPath().matches("/api/v1/login") + && Objects.equals(request.getMethod(), "POST") + && request + .getBody() + .readString(StandardCharsets.UTF_8) + .contains( + String.format( + "username=%s&password=%s", DEFAULT_USERNAME, DEFAULT_PASSWORD))) { + return new MockResponse() + .setResponseCode(200) + .setBody( + "{\"access_token\":\"An AccessToken\",\"token_type\":\"bearer\"," + + "\"expires_in\":null,\"refresh_token\":null,\"scope\":null}"); + } 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 835fe8f88..4200c7b14 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 @@ -19,20 +19,23 @@ import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableSet.toImmutableSet; import static com.google.tsunami.common.net.http.HttpRequest.get; +import static com.google.tsunami.common.net.http.HttpRequest.post; import static java.util.stream.Collectors.joining; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.flogger.GoogleLogger; +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.common.net.http.HttpStatus; -import com.google.tsunami.plugin.PluginType; -import com.google.tsunami.plugin.ServiceFingerprinter; import com.google.tsunami.plugin.annotations.ForWebService; import com.google.tsunami.plugin.annotations.PluginInfo; +import com.google.tsunami.plugin.PluginType; +import com.google.tsunami.plugin.ServiceFingerprinter; import com.google.tsunami.plugins.fingerprinters.web.crawl.Crawler; import com.google.tsunami.plugins.fingerprinters.web.crawl.ScopeUtils; import com.google.tsunami.plugins.fingerprinters.web.data.FingerprintData; @@ -277,6 +280,7 @@ private ImmutableSet detectSoftwareByCustomHeuristics( HashSet detectedSoftware = new HashSet<>(); checkForMlflow(detectedSoftware, networkService, startingUrl); + checkForZenMl(detectedSoftware, networkService, startingUrl); return ImmutableSet.copyOf(detectedSoftware); } @@ -316,4 +320,58 @@ private void checkForMlflow( logger.atWarning().withCause(e).log("Unable to query '%s'.", pingApiUrl); } } + + private void checkForZenMl( + Set software, NetworkService networkService, String startingUrl) { + logger.atInfo().log("probing ZenMl login page and login api - custom fingerprint phase"); + var uriAuthority = NetworkEndpointUtils.toUriAuthority(networkService.getNetworkEndpoint()); + + // we double-check both the api and login page + var loginApiUrl = String.format("http://%s/%s", uriAuthority, "api/v1/login"); + try { + // test login api with a random username and password and for sure not exist + HttpResponse apiLoginResponse = + httpClient.send( + post(loginApiUrl) + .setHeaders( + HttpHeaders.builder() + .addHeader("Content-Type", "application/x-www-form-urlencoded") + .build()) + .setRequestBody( + ByteString.copyFromUtf8( + "username=aHkPdMlQoWjRtBnX&password=aHkPdMlQoWjRtBnX")) + .build()); + + if (!(apiLoginResponse.status() == HttpStatus.UNAUTHORIZED + && apiLoginResponse.bodyString().isPresent() + && apiLoginResponse + .bodyString() + .get() + .equals( + "{\"detail\":[\"AuthorizationException\"," + + "\"Authentication error: invalid username or password\"]}"))) { + return; + } + } catch (IOException e) { + logger.atWarning().withCause(e).log("Unable to query '%s'.", loginApiUrl); + return; + } + + var loginUrl = String.format("http://%s/%s", uriAuthority, "login"); + try { + HttpResponse loginPageResponse = httpClient.send(get(loginUrl).withEmptyHeaders().build()); + if (!(loginPageResponse.bodyString().isPresent() + && loginPageResponse.bodyString().get().contains("ZenML Dashboard"))) { + return; + } + software.add( + DetectedSoftware.builder() + .setSoftwareIdentity(SoftwareIdentity.newBuilder().setSoftware("zenml").build()) + .setRootPath(startingUrl) + .setContentHashes(ImmutableMap.of()) + .build()); + } catch (IOException e) { + logger.atWarning().withCause(e).log("Unable to query '%s'.", loginUrl); + } + } }