From 4ebfab7161e93c51353dcf6224e8450b56009710 Mon Sep 17 00:00:00 2001 From: Daniel Beck Date: Mon, 23 Sep 2024 14:30:21 +0200 Subject: [PATCH] SECURITY-3373 --- pom.xml | 2 +- .../SecretBytesReactionExtension.java | 25 ++++++ .../credentials/SecretBytesRedaction.java | 27 +++++++ .../credentials/SecretBytesRedactionTest.java | 78 +++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/cloudbees/plugins/credentials/SecretBytesReactionExtension.java create mode 100644 src/main/java/com/cloudbees/plugins/credentials/SecretBytesRedaction.java create mode 100644 src/test/java/com/cloudbees/plugins/credentials/SecretBytesRedactionTest.java diff --git a/pom.xml b/pom.xml index d3500c98..02127ec3 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ 999999-SNAPSHOT jenkinsci/${project.artifactId}-plugin - 2.426.3 + 2.462.3 diff --git a/src/main/java/com/cloudbees/plugins/credentials/SecretBytesReactionExtension.java b/src/main/java/com/cloudbees/plugins/credentials/SecretBytesReactionExtension.java new file mode 100644 index 00000000..01ac3dbe --- /dev/null +++ b/src/main/java/com/cloudbees/plugins/credentials/SecretBytesReactionExtension.java @@ -0,0 +1,25 @@ +package com.cloudbees.plugins.credentials; + +import hudson.ExtensionList; +import hudson.init.Initializer; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.security.ExtendedReadRedaction; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +@Restricted(NoExternalUse.class) +public class SecretBytesReactionExtension { + + public static final Logger LOGGER = Logger.getLogger(SecretBytesReactionExtension.class.getName()); + + // TODO Delete this and annotate `SecretBytesRedaction` with `@Extension` once the core dependency is >= 2.479 + @Initializer + public static void create() { + try { + ExtensionList.lookup(ExtendedReadRedaction.class).add(new SecretBytesRedaction()); + } catch (NoClassDefFoundError unused) { + LOGGER.log(Level.WARNING, "Failed to register SecretBytesRedaction. Update Jenkins to add support for redacting credentials in config.xml files from users with ExtendedRead permission. Learn more: https://www.jenkins.io/redirect/plugin/credentials/SecretBytesRedaction/"); + } + } +} diff --git a/src/main/java/com/cloudbees/plugins/credentials/SecretBytesRedaction.java b/src/main/java/com/cloudbees/plugins/credentials/SecretBytesRedaction.java new file mode 100644 index 00000000..2f3a0a89 --- /dev/null +++ b/src/main/java/com/cloudbees/plugins/credentials/SecretBytesRedaction.java @@ -0,0 +1,27 @@ +package com.cloudbees.plugins.credentials; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import jenkins.security.ExtendedReadRedaction; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +@Restricted(NoExternalUse.class) +// @Extension +// See SecretBytesReactionExtension +public class SecretBytesRedaction implements ExtendedReadRedaction { + private static final Pattern SECRET_BYTES_PATTERN = Pattern.compile(">(" + SecretBytes.ENCRYPTED_VALUE_PATTERN + ")<"); + + @Override + public String apply(String configDotXml) { + Matcher matcher = SECRET_BYTES_PATTERN.matcher(configDotXml); + StringBuilder cleanXml = new StringBuilder(); + while (matcher.find()) { + if (SecretBytes.isSecretBytes(matcher.group(1))) { + matcher.appendReplacement(cleanXml, ">********<"); + } + } + matcher.appendTail(cleanXml); + return cleanXml.toString(); + } +} diff --git a/src/test/java/com/cloudbees/plugins/credentials/SecretBytesRedactionTest.java b/src/test/java/com/cloudbees/plugins/credentials/SecretBytesRedactionTest.java new file mode 100644 index 00000000..f11c2b18 --- /dev/null +++ b/src/test/java/com/cloudbees/plugins/credentials/SecretBytesRedactionTest.java @@ -0,0 +1,78 @@ +package com.cloudbees.plugins.credentials; + +import com.cloudbees.hudson.plugins.folder.Folder; +import com.cloudbees.plugins.credentials.domains.Domain; +import com.cloudbees.plugins.credentials.impl.BaseStandardCredentials; +import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl; +import hudson.model.Item; +import hudson.model.ModelObject; +import java.util.Base64; +import java.util.Iterator; +import jenkins.model.Jenkins; +import org.htmlunit.Page; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class SecretBytesRedactionTest { + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + public void testRedaction() throws Exception { + final String usernamePasswordPassword = "thisisthe_theuserpassword"; + final SecretBytes secretBytes = SecretBytes.fromString("thisis_theTestData"); + + Item.EXTENDED_READ.setEnabled(true); + + final Folder folder = j.jenkins.createProject(Folder.class, "F"); + final CredentialsStore store = lookupStore(folder); + final UsernamePasswordCredentialsImpl usernamePasswordCredentials = new UsernamePasswordCredentialsImpl(CredentialsScope.GLOBAL, "passwordid", null, "theusername", usernamePasswordPassword); + store.addCredentials(Domain.global(), usernamePasswordCredentials); + store.addCredentials(Domain.global(), new SecretBytesCredential(CredentialsScope.GLOBAL, "certid", "thedesc", secretBytes)); + + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().grant(Jenkins.ADMINISTER).everywhere().to("alice").grant(Item.READ, Item.EXTENDED_READ, Jenkins.READ).everywhere().to("bob")); + + try (JenkinsRule.WebClient webClient = j.createWebClient().login("alice")) { + final Page page = webClient.goTo("job/F/config.xml", "application/xml"); + final String content = page.getWebResponse().getContentAsString(); + assertThat(content, containsString(usernamePasswordCredentials.getPassword().getEncryptedValue())); + assertThat(content, containsString(Base64.getEncoder().encodeToString(secretBytes.getEncryptedData()))); + } + try (JenkinsRule.WebClient webClient = j.createWebClient().login("bob")) { + final Page page = webClient.goTo("job/F/config.xml", "application/xml"); + final String content = page.getWebResponse().getContentAsString(); + assertThat(content, not(containsString(usernamePasswordCredentials.getPassword().getEncryptedValue()))); + assertThat(content, not(containsString(Base64.getEncoder().encodeToString(secretBytes.getEncryptedData())))); + assertThat(content, containsString("********")); + assertThat(content, containsString("********")); + } + } + + // Stolen from BaseStandardCredentialsTest + private static CredentialsStore lookupStore(ModelObject object) { + Iterator stores = CredentialsProvider.lookupStores(object).iterator(); + assertTrue(stores.hasNext()); + CredentialsStore store = stores.next(); + assertEquals("we got the expected store", object, store.getContext()); + return store; + } + + // This would be nicer with a real credential like `FileCredentialsImpl` but another test falls over if we add `plain-credentials` to the test scope + public static class SecretBytesCredential extends BaseStandardCredentials { + private final SecretBytes mySecretBytes; + + public SecretBytesCredential(CredentialsScope scope, String id, String description, SecretBytes bytes) { + super(scope, id, description); + this.mySecretBytes = bytes; + } + } +}