diff --git a/src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java b/src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java new file mode 100644 index 00000000..96063519 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java @@ -0,0 +1,59 @@ +package org.jenkinsci.plugins.credentialsbinding.masking; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +@Extension +@Restricted(NoExternalUse.class) +public class Base64SecretPatternFactory implements SecretPatternFactory { + @NonNull + @Override + public Collection getEncodedForms(@NonNull String input) { + return getBase64Forms(input); + } + + @NonNull + public Collection getBase64Forms(@NonNull String secret) { + if (secret.length() == 0) { + return Collections.emptyList(); + } + + Base64.Encoder[] encoders = new Base64.Encoder[]{ + Base64.getEncoder(), + Base64.getUrlEncoder(), + }; + + Collection result = new ArrayList<>(); + String[] shifts = {"", "a", "aa"}; + + for (String shift : shifts) { + for (Base64.Encoder encoder : encoders) { + String shiftedSecret = shift + secret; + String encoded = encoder.encodeToString(shiftedSecret.getBytes(StandardCharsets.UTF_8)); + String processedEncoded = shift.length() > 0 ? encoded.substring(2 * shift.length()) : encoded; + result.add(processedEncoded); + result.add(removeTrailingEquals(processedEncoded)); + } + } + return result; + } + + private String removeTrailingEquals(String base64Value) { + if (base64Value.endsWith("==")) { + // removing the last 3 characters, the character before the == being incomplete + return base64Value.substring(0, base64Value.length() - 3); + } + if (base64Value.endsWith("=")) { + // removing the last 2 characters, the character before the = being incomplete + return base64Value.substring(0, base64Value.length() - 2); + } + return base64Value; + } +} diff --git a/src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java b/src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java new file mode 100644 index 00000000..1e48690a --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java @@ -0,0 +1,55 @@ +package org.jenkinsci.plugins.credentialsbinding.masking; + +import static org.hamcrest.Matchers.is; +import static org.jenkinsci.plugins.credentialsbinding.test.Executables.executable; +import static org.junit.Assume.assumeThat; + +import hudson.Functions; +import org.jenkinsci.plugins.credentialsbinding.test.CredentialsTestUtil; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; + +public class Base64SecretPatternFactoryTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + public static final String SAMPLE_PASSWORD = "}#T14'GAz&H!{$U_"; + + @Test + public void base64SecretsAreMaskedInLogs() throws Exception { + WorkflowJob project = j.createProject(WorkflowJob.class); + String credentialsId = CredentialsTestUtil.registerUsernamePasswordCredentials(j.jenkins, "user", SAMPLE_PASSWORD); + String script; + + if (Functions.isWindows()) { + assumeThat("powershell", is(executable())); + script = + " powershell '''\n" + + " $secret = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(\"$env:PASSWORD\"))\n" + + " echo $secret\n" + + " '''\n"; + } else { + script = + " sh '''\n" + + " echo -n $PASSWORD | base64\n" + + " '''\n"; + } + + project.setDefinition(new CpsFlowDefinition( + "node {\n" + + " withCredentials([usernamePassword(credentialsId: '" + credentialsId + "', usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) {\n" + + script + + " }\n" + + "}", true)); + + WorkflowRun run = j.assertBuildStatusSuccess(project.scheduleBuild2(0)); + + j.assertLogContains("****", run); + j.assertLogNotContains(SAMPLE_PASSWORD, run); + } +} \ No newline at end of file diff --git a/src/test/java/org/jenkinsci/plugins/credentialsbinding/test/Base64PatternTest.java b/src/test/java/org/jenkinsci/plugins/credentialsbinding/test/Base64PatternTest.java new file mode 100644 index 00000000..8348f3f6 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/credentialsbinding/test/Base64PatternTest.java @@ -0,0 +1,136 @@ +package org.jenkinsci.plugins.credentialsbinding.test; + +import org.jenkinsci.plugins.credentialsbinding.masking.Base64SecretPatternFactory; +import org.junit.Assert; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collection; + +public class Base64PatternTest { + @Test + public void checkSecretDetected() { + assertBase64PatternFound("abcde", "abcde"); + assertBase64PatternFound("abcde", "1abcde"); + assertBase64PatternFound("abcde", "12abcde"); + assertBase64PatternFound("abcde", "123abcde"); + assertBase64PatternFound("abcde", "abcde1"); + assertBase64PatternFound("abcde", "abcde12"); + assertBase64PatternFound("abcde", "abcde123"); + assertBase64PatternFound("abcde", "1abcde1"); + assertBase64PatternFound("abcde", "1abcde12"); + assertBase64PatternFound("abcde", "1abcde123"); + assertBase64PatternFound("abcde", "12abcde1"); + assertBase64PatternFound("abcde", "12abcde12"); + assertBase64PatternFound("abcde", "12abcde123"); + assertBase64PatternFound("abcde", "123abcde1"); + assertBase64PatternFound("abcde", "123abcde12"); + assertBase64PatternFound("abcde", "123abcde123"); + + assertBase64PatternFound("abcd", "abcde"); + assertBase64PatternFound("abcd", "1abcde"); + assertBase64PatternFound("abcd", "12abcde"); + assertBase64PatternFound("abcd", "123abcde"); + assertBase64PatternFound("abcd", "abcde1"); + assertBase64PatternFound("abcd", "abcde12"); + assertBase64PatternFound("abcd", "abcde123"); + assertBase64PatternFound("abcd", "1abcde1"); + assertBase64PatternFound("abcd", "1abcde12"); + assertBase64PatternFound("abcd", "1abcde123"); + assertBase64PatternFound("abcd", "12abcde1"); + assertBase64PatternFound("abcd", "12abcde12"); + assertBase64PatternFound("abcd", "12abcde123"); + assertBase64PatternFound("abcd", "123abcde1"); + assertBase64PatternFound("abcd", "123abcde12"); + assertBase64PatternFound("abcd", "123abcde123"); + + assertBase64PatternFound("bcd", "abcde"); + assertBase64PatternFound("bcd", "1abcde"); + assertBase64PatternFound("bcd", "12abcde"); + assertBase64PatternFound("bcd", "123abcde"); + assertBase64PatternFound("bcd", "abcde1"); + assertBase64PatternFound("bcd", "abcde12"); + assertBase64PatternFound("bcd", "abcde123"); + assertBase64PatternFound("bcd", "1abcde1"); + assertBase64PatternFound("bcd", "1abcde12"); + assertBase64PatternFound("bcd", "1abcde123"); + assertBase64PatternFound("bcd", "12abcde1"); + assertBase64PatternFound("bcd", "12abcde12"); + assertBase64PatternFound("bcd", "12abcde123"); + assertBase64PatternFound("bcd", "123abcde1"); + assertBase64PatternFound("bcd", "123abcde12"); + assertBase64PatternFound("bcd", "123abcde123"); + } + + @Test + public void checkSecretNotDetected() { + assertBase64PatternNotFound("ab1cde", "abcde"); + assertBase64PatternNotFound("ab1cde", "1abcde"); + assertBase64PatternNotFound("ab1cde", "12abcde"); + assertBase64PatternNotFound("ab1cde", "123abcde"); + assertBase64PatternNotFound("ab1cde", "abcde1"); + assertBase64PatternNotFound("ab1cde", "abcde12"); + assertBase64PatternNotFound("ab1cde", "abcde123"); + assertBase64PatternNotFound("ab1cde", "1abcde1"); + assertBase64PatternNotFound("ab1cde", "1abcde12"); + assertBase64PatternNotFound("ab1cde", "1abcde123"); + assertBase64PatternNotFound("ab1cde", "12abcde1"); + assertBase64PatternNotFound("ab1cde", "12abcde12"); + assertBase64PatternNotFound("ab1cde", "12abcde123"); + assertBase64PatternNotFound("ab1cde", "123abcde1"); + assertBase64PatternNotFound("ab1cde", "123abcde12"); + assertBase64PatternNotFound("ab1cde", "123abcde123"); + + assertBase64PatternNotFound("ab1cd", "abcde"); + assertBase64PatternNotFound("ab1cd", "1abcde"); + assertBase64PatternNotFound("ab1cd", "12abcde"); + assertBase64PatternNotFound("ab1cd", "123abcde"); + assertBase64PatternNotFound("ab1cd", "abcde1"); + assertBase64PatternNotFound("ab1cd", "abcde12"); + assertBase64PatternNotFound("ab1cd", "abcde123"); + assertBase64PatternNotFound("ab1cd", "1abcde1"); + assertBase64PatternNotFound("ab1cd", "1abcde12"); + assertBase64PatternNotFound("ab1cd", "1abcde123"); + assertBase64PatternNotFound("ab1cd", "12abcde1"); + assertBase64PatternNotFound("ab1cd", "12abcde12"); + assertBase64PatternNotFound("ab1cd", "12abcde123"); + assertBase64PatternNotFound("ab1cd", "123abcde1"); + assertBase64PatternNotFound("ab1cd", "123abcde12"); + assertBase64PatternNotFound("ab1cd", "123abcde123"); + + assertBase64PatternNotFound("b1cd", "abcde"); + assertBase64PatternNotFound("b1cd", "1abcde"); + assertBase64PatternNotFound("b1cd", "12abcde"); + assertBase64PatternNotFound("b1cd", "123abcde"); + assertBase64PatternNotFound("b1cd", "abcde1"); + assertBase64PatternNotFound("b1cd", "abcde12"); + assertBase64PatternNotFound("b1cd", "abcde123"); + assertBase64PatternNotFound("b1cd", "1abcde1"); + assertBase64PatternNotFound("b1cd", "1abcde12"); + assertBase64PatternNotFound("b1cd", "1abcde123"); + assertBase64PatternNotFound("b1cd", "12abcde1"); + assertBase64PatternNotFound("b1cd", "12abcde12"); + assertBase64PatternNotFound("b1cd", "12abcde123"); + assertBase64PatternNotFound("b1cd", "123abcde1"); + assertBase64PatternNotFound("b1cd", "123abcde12"); + assertBase64PatternNotFound("b1cd", "123abcde123"); + } + + private void assertBase64PatternFound(String secret, String plainText) { + Assert.assertTrue("Pattern " + plainText + " not detected as containing " + secret, isPatternContainingSecret(secret, plainText)); + } + + private void assertBase64PatternNotFound(String secret, String plainText) { + Assert.assertFalse("Pattern " + plainText + " was detected as containing " + secret, isPatternContainingSecret(secret, plainText)); + } + + public boolean isPatternContainingSecret(String secret, String plainText) { + Base64SecretPatternFactory factory = new Base64SecretPatternFactory(); + Collection allPatterns = factory.getBase64Forms(secret); + + String base64Text = Base64.getEncoder().encodeToString(plainText.getBytes(StandardCharsets.UTF_8)); + + return allPatterns.stream().anyMatch(base64Text::contains); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/credentialsbinding/test/CredentialsTestUtil.java b/src/test/java/org/jenkinsci/plugins/credentialsbinding/test/CredentialsTestUtil.java index b5b55481..4642c942 100644 --- a/src/test/java/org/jenkinsci/plugins/credentialsbinding/test/CredentialsTestUtil.java +++ b/src/test/java/org/jenkinsci/plugins/credentialsbinding/test/CredentialsTestUtil.java @@ -26,7 +26,9 @@ import com.cloudbees.plugins.credentials.CredentialsProvider; import com.cloudbees.plugins.credentials.CredentialsScope; +import com.cloudbees.plugins.credentials.common.UsernamePasswordCredentials; import com.cloudbees.plugins.credentials.domains.Domain; +import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl; import hudson.model.ModelObject; import hudson.util.Secret; import org.jenkinsci.plugins.plaincredentials.StringCredentials; @@ -55,4 +57,23 @@ public static void setStringCredentials(ModelObject context, String credentialsI StringCredentials creds = new StringCredentialsImpl(CredentialsScope.GLOBAL, credentialsId, null, Secret.fromString(value)); CredentialsProvider.lookupStores(context).iterator().next().addCredentials(Domain.global(), creds); } + + /** + * Registers the given value as a {@link UsernamePasswordCredentials} into the default {@link CredentialsProvider}. + * Returns the generated credential id for the registered credentials. + */ + public static String registerUsernamePasswordCredentials(ModelObject context, String username, String password) throws IOException { + String credentialsId = UUID.randomUUID().toString(); + setUsernamePasswordCredentials(context, credentialsId, username, password); + return credentialsId; + } + + /** + * Registers the given value as a {@link UsernamePasswordCredentials} into the default {@link CredentialsProvider} using the + * specified credentials id. + */ + public static void setUsernamePasswordCredentials(ModelObject context, String credentialsId, String username, String password) throws IOException { + UsernamePasswordCredentials creds = new UsernamePasswordCredentialsImpl(CredentialsScope.GLOBAL, credentialsId, null, username, password); + CredentialsProvider.lookupStores(context).iterator().next().addCredentials(Domain.global(), creds); + } }