From 391573e8c8c7f6fe92b08f09ba026a24c1899fa0 Mon Sep 17 00:00:00 2001 From: Kevin-CB Date: Wed, 6 Sep 2023 17:18:55 +0200 Subject: [PATCH 1/2] Add base64 masking --- .../masking/Base64SecretPatternFactory.java | 60 +++++++++++++++++++ .../Base64SecretPatternFactoryTest.java | 50 ++++++++++++++++ .../test/CredentialsTestUtil.java | 21 +++++++ 3 files changed, 131 insertions(+) create mode 100644 src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java create mode 100644 src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java 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..d4a2168f --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java @@ -0,0 +1,60 @@ +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 + private Collection getBase64Forms(@NonNull String secret) { + if (secret.length() == 0) { + return Collections.emptyList(); + } + + Base64.Encoder encoder = Base64.getEncoder(); + Collection result = new ArrayList<>(3); + + // default + String regularBase64 = encoder.encodeToString(secret.getBytes(StandardCharsets.UTF_8)); + result.add(regularBase64); + result.add(removeTrailingEquals(regularBase64)); + + // shifted by one + String shiftedByOne = encoder.encodeToString(("a" + secret).getBytes(StandardCharsets.UTF_8)); + result.add(shiftedByOne.substring(2)); + result.add(removeTrailingEquals(shiftedByOne.substring(2))); + + // shifted by two + String shiftedByTwo = encoder.encodeToString(("aa" + secret).getBytes(StandardCharsets.UTF_8)); + result.add(shiftedByTwo.substring(4)); + result.add(removeTrailingEquals(shiftedByTwo.substring(4))); + + 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..9c324e79 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java @@ -0,0 +1,50 @@ +package org.jenkinsci.plugins.credentialsbinding.masking; + +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()) { + 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/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); + } } From d68513672d0287dca7bf97cbd62e1f89daa8eef5 Mon Sep 17 00:00:00 2001 From: Kevin-CB Date: Thu, 7 Sep 2023 13:52:22 +0200 Subject: [PATCH 2/2] Address review feedback --- .../masking/Base64SecretPatternFactory.java | 37 +++-- .../Base64SecretPatternFactoryTest.java | 7 +- .../test/Base64PatternTest.java | 136 ++++++++++++++++++ 3 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 src/test/java/org/jenkinsci/plugins/credentialsbinding/test/Base64PatternTest.java diff --git a/src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java b/src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java index d4a2168f..96063519 100644 --- a/src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java +++ b/src/main/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactory.java @@ -20,29 +20,28 @@ public Collection getEncodedForms(@NonNull String input) { } @NonNull - private Collection getBase64Forms(@NonNull String secret) { + public Collection getBase64Forms(@NonNull String secret) { if (secret.length() == 0) { return Collections.emptyList(); } - Base64.Encoder encoder = Base64.getEncoder(); - Collection result = new ArrayList<>(3); - - // default - String regularBase64 = encoder.encodeToString(secret.getBytes(StandardCharsets.UTF_8)); - result.add(regularBase64); - result.add(removeTrailingEquals(regularBase64)); - - // shifted by one - String shiftedByOne = encoder.encodeToString(("a" + secret).getBytes(StandardCharsets.UTF_8)); - result.add(shiftedByOne.substring(2)); - result.add(removeTrailingEquals(shiftedByOne.substring(2))); - - // shifted by two - String shiftedByTwo = encoder.encodeToString(("aa" + secret).getBytes(StandardCharsets.UTF_8)); - result.add(shiftedByTwo.substring(4)); - result.add(removeTrailingEquals(shiftedByTwo.substring(4))); - + 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; } diff --git a/src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java b/src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java index 9c324e79..1e48690a 100644 --- a/src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java +++ b/src/test/java/org/jenkinsci/plugins/credentialsbinding/masking/Base64SecretPatternFactoryTest.java @@ -1,5 +1,9 @@ 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; @@ -17,12 +21,13 @@ public class Base64SecretPatternFactoryTest { public static final String SAMPLE_PASSWORD = "}#T14'GAz&H!{$U_"; @Test - public void Base64SecretsAreMaskedInLogs() throws Exception { + 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" 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); + } +}