CVE-2019-10476
Description
Jenkins Zulip Plugin stored API keys in plain text in global config, allowing users with file system access to view credentials.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Jenkins Zulip Plugin stored API keys in plain text in global config, allowing users with file system access to view credentials.
Vulnerability
Description
The Jenkins Zulip Plugin, versions 1.1.0 and earlier, stored the Zulip API key as a plain text string in its global configuration file (jenkins.plugins.zulip.ZulipNotifier.xml) and in a legacy configuration file (hudson.plugins.humbug.HumbugNotifier.xml). This is a classic example of storing sensitive credentials in cleartext, making them accessible to anyone who can read the Jenkins master's file system [1][2].
Exploitation
An attacker does not need any special permissions within Jenkins itself to exploit this vulnerability; they only require access to the Jenkins master's file system. This could be achieved through other vulnerabilities, direct shell access, or by reading files via a compromised Jenkins agent. Once the configuration file is read, the API key is immediately visible as a plain text string [2][3].
Impact
With the exposed API key, an attacker can send messages to Zulip streams as the configured bot, potentially disrupting notifications, impersonating the bot, or leaking information. The Jenkins Security Advisory rates this vulnerability as Low severity (CVSS score not explicitly given but implied low) [2]. The impact is limited to the Zulip integration's functionality and does not directly compromise the Jenkins controller itself.
Mitigation
The vulnerability is fixed in Zulip Plugin version 1.1.1. The fix changes the apiKey field from a plain String to a hudson.util.Secret object, which encrypts the value when stored on disk [1]. Users should upgrade to version 1.1.1 or later. No workaround is available other than restricting file system access to the Jenkins master [2][4].
AI Insight generated on May 22, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.jenkins-ci.plugins:zulipMaven | < 1.1.1 | 1.1.1 |
Affected products
2- Range: 1.1.0 and earlier
Patches
12a9dd6c41c2dSECURITY-1621 Store global config API key as Secret
7 files changed · +56 −20
src/main/java/jenkins/plugins/zulip/DescriptorImpl.java+19 −5 modified@@ -9,6 +9,7 @@ import hudson.model.AbstractProject; import hudson.tasks.BuildStepDescriptor; import hudson.tasks.Publisher; +import hudson.util.Secret; import hudson.util.XStream2; import jenkins.model.Jenkins; import net.sf.json.JSONObject; @@ -23,9 +24,11 @@ public class DescriptorImpl extends BuildStepDescriptor<Publisher> { private static final Logger logger = Logger.getLogger(DescriptorImpl.class.getName()); + private static final String OLD_CONFIG_FILE_NAME = "hudson.plugins.humbug.HumbugNotifier.xml"; + private String url; private String email; - private String apiKey; + private Secret apiKey; private String stream; private String topic; private transient String hudsonUrl; // backwards compatibility @@ -40,7 +43,7 @@ public DescriptorImpl() { } else { XStream2 xstream = new XStream2(); xstream.alias("hudson.plugins.humbug.DescriptorImpl", DescriptorImpl.class); - XmlFile oldConfig = new XmlFile(xstream, new File(Jenkins.getInstance().getRootDir(),"hudson.plugins.humbug.HumbugNotifier.xml")); + XmlFile oldConfig = new XmlFile(xstream, new File(Jenkins.getInstance().getRootDir(), OLD_CONFIG_FILE_NAME)); if (oldConfig.exists()) { try { oldConfig.unmarshal(this); @@ -67,11 +70,11 @@ public void setEmail(String email) { this.email = email; } - public String getApiKey() { + public Secret getApiKey() { return apiKey; } - public void setApiKey(String apiKey) { + public void setApiKey(Secret apiKey) { this.apiKey = apiKey; } @@ -115,12 +118,23 @@ public boolean isApplicable(Class<? extends AbstractProject> aClass) { public boolean configure(StaplerRequest req, JSONObject json) throws FormException { url = (String) json.get("url"); email = (String) json.get("email"); - apiKey = (String) json.get("apiKey"); + apiKey = Secret.fromString((String) json.get("apiKey")); stream = (String) json.get("stream"); topic = (String) json.get("topic"); jenkinsUrl = (String) json.get("jenkinsUrl"); smartNotify = Boolean.TRUE.equals(json.get("smartNotify")); save(); + + // Cleanup the configuration file from previous plugin id - humbug + File oldConfig = new File(Jenkins.getInstance().getRootDir(), OLD_CONFIG_FILE_NAME); + if (oldConfig.exists()) { + if (oldConfig.delete()) { + logger.log(Level.INFO, "Old humbug configuration file successfully cleaned up."); + } else { + logger.log(Level.WARNING, "Failed to cleanup old humbug configuration file."); + } + } + return super.configure(req, json); }
src/main/java/jenkins/plugins/zulip/Zulip.java+3 −2 modified@@ -10,6 +10,7 @@ import java.util.logging.Logger; import hudson.ProxyConfiguration; +import hudson.util.Secret; import jenkins.model.Jenkins; import org.apache.commons.codec.binary.Base64; import org.apache.commons.httpclient.HttpClient; @@ -32,14 +33,14 @@ public class Zulip { private String apiKey; private static final Logger LOGGER = Logger.getLogger(Zulip.class.getName()); - public Zulip(String url, String email, String apiKey) { + public Zulip(String url, String email, Secret apiKey) { super(); if (url != null && url.length() > 0 && !url.endsWith("/") ) { url = url + "/"; } this.url = url; this.email = email; - this.apiKey = apiKey; + this.apiKey = Secret.toString(apiKey); } /**
src/main/resources/jenkins/plugins/zulip/ZulipNotifier/global.jelly+1 −1 modified@@ -21,7 +21,7 @@ <f:textbox name="email" value="${descriptor.getEmail()}" /> </f:entry> <f:entry title="Zulip API Key" help="/plugin/zulip/help-globalConfig-apiKey.html"> - <f:textbox name="apiKey" value="${descriptor.getApiKey()}" /> + <f:password name="apiKey" value="${descriptor.getApiKey()}" /> </f:entry> <f:entry title="Default Stream Name" help="/plugin/zulip/help-globalConfig-stream.html"> <f:textbox name="stream" value="${descriptor.getStream()}" />
src/test/java/jenkins/plugins/zulip/IntegrationTest.java+2 −1 modified@@ -3,6 +3,7 @@ import com.gargoylesoftware.htmlunit.html.HtmlForm; import com.gargoylesoftware.htmlunit.html.HtmlPage; import hudson.model.FreeStyleProject; +import hudson.util.Secret; import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; import org.jenkinsci.plugins.workflow.job.WorkflowJob; import org.junit.AfterClass; @@ -120,7 +121,7 @@ private void verifyGlobalConfig() { DescriptorImpl globalConfig = j.jenkins.getDescriptorByType(DescriptorImpl.class); assertEquals("ZulipUrl", globalConfig.getUrl()); assertEquals("jenkins-bot@zulip.com", globalConfig.getEmail()); - assertEquals("secret", globalConfig.getApiKey()); + assertEquals("secret", Secret.toString(globalConfig.getApiKey())); assertEquals("defaultStream", globalConfig.getStream()); assertEquals("defaultTopic", globalConfig.getTopic()); assertTrue(globalConfig.isSmartNotify());
src/test/java/jenkins/plugins/zulip/ZulipNotifierTest.java+10 −3 modified@@ -13,6 +13,7 @@ import hudson.model.User; import hudson.scm.ChangeLogSet; import hudson.tasks.test.AbstractTestResultAction; +import hudson.util.Secret; import jenkins.model.Jenkins; import org.junit.Before; import org.junit.Test; @@ -31,7 +32,10 @@ import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThat; +import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Matchers.same; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -40,7 +44,7 @@ @RunWith(PowerMockRunner.class) @PrepareForTest({Jenkins.class, User.class, ZulipNotifier.class, DescriptorImpl.class, - AbstractBuild.class, Job.class}) + AbstractBuild.class, Job.class, Secret.class}) public class ZulipNotifierTest { private static final int TOTAL_TEST_COUNT = 100; @@ -49,6 +53,9 @@ public class ZulipNotifierTest { @Mock private Jenkins jenkins; + @Mock + private Secret secret; + @Mock private Zulip zulip; @@ -99,7 +106,7 @@ public User answer(InvocationOnMock invocation) throws Throwable { }); when(descMock.getUrl()).thenReturn("zulipUrl"); when(descMock.getEmail()).thenReturn("jenkins-bot@zulip.com"); - when(descMock.getApiKey()).thenReturn("secret"); + when(descMock.getApiKey()).thenReturn(secret); when(descMock.getStream()).thenReturn("defaultStream"); when(descMock.getTopic()).thenReturn("defaultTopic"); PowerMockito.whenNew(DescriptorImpl.class).withAnyArguments().thenReturn(descMock); @@ -123,7 +130,7 @@ public String answer(InvocationOnMock invocation) throws Throwable { public void testShouldUseDefaults() throws Exception { ZulipNotifier notifier = new ZulipNotifier(); notifier.perform(build, null, buildListener); - verifyNew(Zulip.class).withArguments("zulipUrl", "jenkins-bot@zulip.com", "secret"); + verifyNew(Zulip.class).withArguments(eq("zulipUrl"), eq("jenkins-bot@zulip.com"), any(Secret.class)); verify(envVars, times(2)).expand(expandCaptor.capture()); assertThat("Should expand stream, topic and message", expandCaptor.getAllValues(), is(Arrays.asList("defaultStream", "defaultTopic")));
src/test/java/jenkins/plugins/zulip/ZulipSendStepTest.java+9 −3 modified@@ -6,6 +6,7 @@ import hudson.model.Job; import hudson.model.Run; import hudson.model.TaskListener; +import hudson.util.Secret; import jenkins.model.Jenkins; import org.junit.Before; import org.junit.Test; @@ -23,20 +24,25 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThat; +import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.powermock.api.mockito.PowerMockito.verifyNew; import static org.powermock.api.mockito.PowerMockito.when; @RunWith(PowerMockRunner.class) -@PrepareForTest({Jenkins.class, ZulipSendStep.class}) +@PrepareForTest({Jenkins.class, ZulipSendStep.class, Secret.class}) public class ZulipSendStepTest { @Mock private Jenkins jenkins; + @Mock + private Secret secret; + @Mock private Zulip zulip; @@ -75,7 +81,7 @@ public void setUp() throws Exception { when(jenkins.getDescriptorByType(DescriptorImpl.class)).thenReturn(descMock); when(descMock.getUrl()).thenReturn("zulipUrl"); when(descMock.getEmail()).thenReturn("jenkins-bot@zulip.com"); - when(descMock.getApiKey()).thenReturn("secret"); + when(descMock.getApiKey()).thenReturn(secret); when(descMock.getStream()).thenReturn("defaultStream"); when(descMock.getTopic()).thenReturn("defaultTopic"); when(run.getParent()).thenReturn(job); @@ -94,7 +100,7 @@ public void testShouldUseDefaults() throws Exception { ZulipSendStep sendStep = new ZulipSendStep(); sendStep.setMessage("message"); sendStep.perform(run, null, null, taskListener); - verifyNew(Zulip.class).withArguments("zulipUrl", "jenkins-bot@zulip.com", "secret"); + verifyNew(Zulip.class).withArguments(eq("zulipUrl"), eq("jenkins-bot@zulip.com"), any(Secret.class)); verify(envVars, times(3)).expand(expandCaptor.capture()); assertThat("Should expand stream, topic and message", expandCaptor.getAllValues(), is(Arrays.asList("defaultStream", "defaultTopic", "message")));
src/test/java/jenkins/plugins/zulip/ZulipTest.java+12 −5 modified@@ -4,6 +4,7 @@ import java.nio.charset.Charset; import com.google.common.net.HttpHeaders; +import hudson.util.Secret; import jenkins.model.Jenkins; import org.apache.commons.httpclient.NameValuePair; import org.junit.AfterClass; @@ -18,20 +19,24 @@ import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; +import static org.mockito.Matchers.any; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; import static org.mockserver.model.StringBody.exact; import static org.powermock.api.mockito.PowerMockito.when; @RunWith(PowerMockRunner.class) -@PrepareForTest(Jenkins.class) +@PrepareForTest({Jenkins.class, Secret.class}) public class ZulipTest { private static ClientAndServer mockServer; @Mock private Jenkins jenkins; + @Mock + private Secret secret; + @BeforeClass public static void startMockServer() { mockServer = ClientAndServer.startClientAndServer(1080); @@ -46,13 +51,15 @@ public static void stopMockServer() { public void setUp() { PowerMockito.mockStatic(Jenkins.class); when(Jenkins.getInstance()).thenReturn(jenkins); + PowerMockito.mockStatic(Secret.class); + when(Secret.toString(any(Secret.class))).thenReturn("secret"); } @Test public void testSendStreamMessage() throws Exception { mockServer.reset(); mockServer.when(request().withPath("/api/v1/messages")).respond(response().withStatusCode(200)); - Zulip zulip = new Zulip("http://localhost:1080", "jenkins-bot@zulip.com", "secret"); + Zulip zulip = new Zulip("http://localhost:1080", "jenkins-bot@zulip.com", secret); zulip.sendStreamMessage("testStreamůř", "testTopic", "testMessage"); mockServer.verify( request() @@ -78,21 +85,21 @@ public void testSendStreamMessage() throws Exception { public void testFailGracefullyOnError() { mockServer.reset(); mockServer.when(request().withPath("/api/v1/messages")).respond(response().withStatusCode(500)); - Zulip zulip = new Zulip("http://localhost:1080", "jenkins-bot@zulip.com", "secret"); + Zulip zulip = new Zulip("http://localhost:1080", "jenkins-bot@zulip.com", secret); // Test that this does not throw exception zulip.sendStreamMessage("testStream", "testTopic", "testMessage"); } @Test public void testFailGracefullyWhenUnreachable() { - Zulip zulip = new Zulip("http://localhost:1081", "jenkins-bot@zulip.com", "secret"); + Zulip zulip = new Zulip("http://localhost:1081", "jenkins-bot@zulip.com", secret); // Test that this does not throw exception zulip.sendStreamMessage("testStream", "testTopic", "testMessage"); } @Test public void testFailGracefullyUnknonwHost() { - Zulip zulip = new Zulip("http://unreachable:1080", "jenkins-bot@zulip.com", "secret"); + Zulip zulip = new Zulip("http://unreachable:1080", "jenkins-bot@zulip.com", secret); // Test that this does not throw exception zulip.sendStreamMessage("testStream", "testTopic", "testMessage"); }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-hfjr-m75m-wmh7ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2019-10476ghsaADVISORY
- www.openwall.com/lists/oss-security/2019/10/23/2ghsamailing-listx_refsource_MLISTWEB
- github.com/jenkinsci/zulip-plugin/commit/2a9dd6c41c2d913b0414d015b3118e3ddb60bd90ghsaWEB
- github.com/jenkinsci/zulip-plugin/releases/tag/1.1.1ghsaWEB
- jenkins.io/security/advisory/2019-10-23/ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.