diff --git a/pom.xml b/pom.xml index 417f184..fc20da6 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ 1.0 -SNAPSHOT - 2.161 + 2.169-SNAPSHOT 8 2.11.1 1.0.1 @@ -161,6 +161,10 @@ repo.jenkins-ci.org https://repo.jenkins-ci.org/public/ + + jenkins-2.169-snapshot + https://repo.jenkins-ci.org/snapshots/ + diff --git a/src/main/java/io/jenkins/plugins/audit/listeners/ApiKeyCreationListener.java b/src/main/java/io/jenkins/plugins/audit/listeners/ApiKeyCreationListener.java new file mode 100644 index 0000000..ad2d5a0 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/audit/listeners/ApiKeyCreationListener.java @@ -0,0 +1,64 @@ +package io.jenkins.plugins.audit.listeners; + +/* + * The MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import hudson.Extension; +import hudson.ExtensionList; +import hudson.model.UserProperty; + +import io.jenkins.plugins.audit.event.CreateKey; +import jenkins.security.ApiTokenProperty; +import jenkins.security.ApiTokenPropertyListener; +import org.apache.logging.log4j.audit.LogEventFactory; + +import javax.annotation.Nonnull; + +/** + * Listener notified of api token key creation events. + */ +@Extension +public class ApiKeyCreationListener extends ApiTokenPropertyListener { + + /** + * Fired when a new user property has been created. + * + * @param username the user + * @param value property that was newly created. + * + */ + @Override + public void onCreated(@Nonnull String username, @Nonnull UserProperty value) { + if (value instanceof ApiTokenProperty) { + CreateKey user = LogEventFactory.getEvent(CreateKey.class); + + user.setUserId(username); + user.logEvent(); + } + } + + /** + * Returns a registered {@link ApiKeyCreationListener} instance. + */ + public static ExtensionList getInstance() { return ExtensionList.lookup(ApiKeyCreationListener.class); } + +} diff --git a/src/main/java/io/jenkins/plugins/audit/listeners/ApiKeyDeletionListener.java b/src/main/java/io/jenkins/plugins/audit/listeners/ApiKeyDeletionListener.java new file mode 100644 index 0000000..c2fe55f --- /dev/null +++ b/src/main/java/io/jenkins/plugins/audit/listeners/ApiKeyDeletionListener.java @@ -0,0 +1,64 @@ +package io.jenkins.plugins.audit.listeners; + +/* + * The MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import hudson.Extension; +import hudson.ExtensionList; +import hudson.model.UserProperty; + +import io.jenkins.plugins.audit.event.DeleteKey; +import jenkins.security.ApiTokenProperty; +import jenkins.security.ApiTokenPropertyListener; +import org.apache.logging.log4j.audit.LogEventFactory; + +import javax.annotation.Nonnull; + +/** + * Listener notified of api token key deletion or revocation events. + */ +@Extension +public class ApiKeyDeletionListener extends ApiTokenPropertyListener { + + /** + * Fired when an api token has been revoked + * + * @param username id of the user + * @param value api token property of the user + * + */ + @Override + public void onDeleted(@Nonnull String username, @Nonnull UserProperty value) { + if (value instanceof ApiTokenProperty) { + DeleteKey user = LogEventFactory.getEvent(DeleteKey.class); + + user.setUserId(username); + user.logEvent(); + } + } + + /** + * Returns a registered {@link ApiKeyDeletionListener} instance. + */ + public static ExtensionList getInstance() { return ExtensionList.lookup(ApiKeyDeletionListener.class); } + +} diff --git a/src/main/java/io/jenkins/plugins/audit/listeners/UserPasswordLogListener.java b/src/main/java/io/jenkins/plugins/audit/listeners/UserPasswordLogListener.java new file mode 100644 index 0000000..8e2353f --- /dev/null +++ b/src/main/java/io/jenkins/plugins/audit/listeners/UserPasswordLogListener.java @@ -0,0 +1,61 @@ +package io.jenkins.plugins.audit.listeners; + +/* + * The MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import hudson.security.PasswordPropertyListener; +import io.jenkins.plugins.audit.event.UpdatePassword; +import org.apache.logging.log4j.audit.LogEventFactory; + +import hudson.Extension; +import hudson.ExtensionList; + +import javax.annotation.Nonnull; + +/** + * Listener which logs password-update audit events. + */ +@Extension +public class UserPasswordLogListener extends PasswordPropertyListener { + + /** + * Fired when a user password property has been updated and will log the event. + * + * @param username the user + * @param oldValue old property of the user + * @param newValue new property of the user + * + */ + @Override + public void onChanged(@Nonnull String username, @Nonnull Object oldValue, @Nonnull Object newValue) { + UpdatePassword user = LogEventFactory.getEvent(UpdatePassword.class); + + user.setUserId(username); + user.logEvent(); + } + + /** + * Returns a registered {@link UserPasswordLogListener} instance. + */ + public static ExtensionList getInstance() { return ExtensionList.lookup(UserPasswordLogListener.class); } + +} diff --git a/src/main/resources/catalog.json b/src/main/resources/catalog.json index 01f6ae3..342c325 100644 --- a/src/main/resources/catalog.json +++ b/src/main/resources/catalog.json @@ -16,32 +16,45 @@ } ] }, { "id" : 2, - "name" : "updateKey", - "displayName" : "Update Key", - "description" : "User updates API key(s)", + "name" : "createKey", + "displayName" : "Create Key", + "description" : "User creates a new API key", "aliases" : [ ], "attributes" : [ { "name" : "userId", - "required" : true + "required" : false }, { "name" : "timestamp", - "required" : true + "required" : false } ] }, { "id" : 3, + "name" : "deleteKey", + "displayName" : "Delete Key", + "description" : "User deletes or revokes an existing API key", + "aliases" : [ ], + "attributes" : [ { + "name" : "userId", + "required" : false + }, { + "name" : "timestamp", + "required" : false + } ] + }, { + "id" : 4, "name" : "updatePassword", "displayName" : "Update Password", "description" : "User updates password", "aliases" : [ ], "attributes" : [ { "name" : "userId", - "required" : true + "required" : false }, { "name" : "timestamp", - "required" : true + "required" : false } ] }, { - "id" : 4, + "id" : 5, "name" : "logout", "displayName" : "Logout", "description" : "User Logout", @@ -54,7 +67,7 @@ "required" : false } ] }, { - "id" : 5, + "id" : 6, "name" : "createUser", "displayName" : "Create User", "description" : "Create User", @@ -67,7 +80,7 @@ "required" : false } ] }, { - "id" : 6, + "id" : 7, "name" : "buildStart", "displayName" : "Build Start", "description" : "Start of the Build", diff --git a/src/test/java/io/jenkins/plugins/audit/listeners/ApiKeyCreationListenerTest.java b/src/test/java/io/jenkins/plugins/audit/listeners/ApiKeyCreationListenerTest.java new file mode 100644 index 0000000..0eb131f --- /dev/null +++ b/src/test/java/io/jenkins/plugins/audit/listeners/ApiKeyCreationListenerTest.java @@ -0,0 +1,117 @@ +package io.jenkins.plugins.audit.listeners; + +/* + * The MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import hudson.security.HudsonPrivateSecurityRealm; + +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.Page; +import jenkins.security.ApiTokenProperty; +import org.apache.commons.lang.StringUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.After; +import org.junit.Before; +import org.jvnet.hudson.test.Issue; +import org.xml.sax.SAXException; +import org.jvnet.hudson.test.JenkinsRule; +import org.apache.logging.log4j.core.LogEvent; +import org.jvnet.hudson.test.JenkinsRule.WebClient; +import org.apache.logging.log4j.test.appender.ListAppender; +import org.apache.logging.log4j.message.StructuredDataMessage; + +import java.util.List; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.URL; + +import com.gargoylesoftware.htmlunit.html.HtmlPage; + +import static org.junit.Assert.*; + +import hudson.model.User; +import hudson.security.pages.SignupPage; + +public class ApiKeyCreationListenerTest { + + private ListAppender app; + private WebClient client; + + private static void assertEventCount(final List events, final int expected) { + assertEquals("Incorrect number of events.", expected, events.size()); + } + + private static WebClient logout(final WebClient wc) throws IOException, SAXException { + wc.goTo("logout"); + return wc; + } + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Before + public void setup() throws Exception { + // user ID conformance check + Field field = HudsonPrivateSecurityRealm.class.getDeclaredField("ID_REGEX"); + field.setAccessible(true); + field.set(null, null); + + client = j.createWebClient(); + logout(client); + + app = ListAppender.getListAppender("AuditList").clear(); + } + + @After + public void teardown() { + app.clear(); + } + + @Issue("JENKINS-55694") + @Test + public void createUserToken() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(true, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + List events = app.getEvents(); + assertEventCount(events, 0); + + // new user account creation + SignupPage signup = new SignupPage(client.goTo("signup")); + signup.enterUsername("charlie"); + signup.enterPassword("charlie"); + signup.enterFullName(StringUtils.capitalize("charlie user")); + HtmlPage page = signup.submit(j); + + // execute an http request to create a new a user api token from their config page + User charlie = User.getById("charlie", false); + URL configPage = client.createCrumbedUrl(charlie.getUrl() + "/" + "/descriptorByName/" + ApiTokenProperty.class.getName() + "/generateNewToken/?newTokenName=" + "charlie-token"); + Page p = client.getPage(new WebRequest(configPage, HttpMethod.POST)); + + // ensure user whose api token was created was in fact logged + StructuredDataMessage logMessage = (StructuredDataMessage) events.get(2).getMessage(); + assertTrue(logMessage.toString().contains("createKey")); + assertEventCount(events, 3); + } +} diff --git a/src/test/java/io/jenkins/plugins/audit/listeners/ApiKeyDeletionListenerTest.java b/src/test/java/io/jenkins/plugins/audit/listeners/ApiKeyDeletionListenerTest.java new file mode 100644 index 0000000..5f434ca --- /dev/null +++ b/src/test/java/io/jenkins/plugins/audit/listeners/ApiKeyDeletionListenerTest.java @@ -0,0 +1,128 @@ +package io.jenkins.plugins.audit.listeners; + +/* + * The MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import hudson.model.User; +import hudson.security.HudsonPrivateSecurityRealm; +import hudson.security.pages.SignupPage; +import jenkins.security.ApiTokenProperty; +import net.sf.json.JSONObject; +import org.apache.commons.lang.StringUtils; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.message.StructuredDataMessage; +import org.apache.logging.log4j.test.appender.ListAppender; +import org.junit.*; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.JenkinsRule.WebClient; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.URL; +import java.util.List; + +import static org.junit.Assert.*; + +public class ApiKeyDeletionListenerTest { + + private ListAppender app; + private WebClient client; + + private static void assertEventCount(final List events, final int expected) { + assertEquals("Incorrect number of events.", expected, events.size()); + } + + private static WebClient logout(final WebClient wc) throws IOException, SAXException { + wc.goTo("logout"); + return wc; + } + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Before + public void setup() throws Exception { + // user ID conformance check + Field field = HudsonPrivateSecurityRealm.class.getDeclaredField("ID_REGEX"); + field.setAccessible(true); + field.set(null, null); + + client = j.createWebClient(); + logout(client); + + app = ListAppender.getListAppender("AuditList").clear(); + } + + @After + public void teardown() { + app.clear(); + } + + @Issue("JENKINS-55694") + @Test + public void revokeUserToken() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(true, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + List events = app.getEvents(); + assertEventCount(events, 0); + + // new user account creation + SignupPage signup = new SignupPage(client.goTo("signup")); + signup.enterUsername("alice"); + signup.enterPassword("alice"); + signup.enterFullName(StringUtils.capitalize("alice user")); + HtmlPage page = signup.submit(j); + + // execute an http request to delete an existing user api token from their config page + User alice = User.getById("alice", false); + URL configPage = client.createCrumbedUrl(alice.getUrl() + "/" + "/descriptorByName/" + ApiTokenProperty.class.getName() + "/generateNewToken/?newTokenName=" + "alice-token"); + Page p = client.getPage(new WebRequest(configPage, HttpMethod.POST)); + JSONObject responseJson = JSONObject.fromObject(p.getWebResponse().getContentAsString()); + GenerateNewTokenResponse userToken = (GenerateNewTokenResponse) responseJson.getJSONObject("data").toBean(GenerateNewTokenResponse.class); + assertNotNull(userToken.tokenUuid); + + // execute a second http request to delete the just created user api token from their config page + configPage = client.createCrumbedUrl(alice.getUrl() + "/" + "/descriptorByName/" + ApiTokenProperty.class.getName() + "/revoke/?tokenUuid=" + userToken.tokenUuid); + p = client.getPage(new WebRequest(configPage, HttpMethod.POST)); + + // ensure user whose api token was deleted was in fact logged + StructuredDataMessage logMessage = (StructuredDataMessage) events.get(3).getMessage(); + assertTrue(logMessage.toString().contains("deleteKey")); + assertEventCount(events, 4); + } + + /** + * Static class representing the returned api token response + */ + public static class GenerateNewTokenResponse { + public String tokenUuid; + public String tokenName; + public String tokenValue; + } +} diff --git a/src/test/java/io/jenkins/plugins/audit/listeners/UserPasswordLogListenerTest.java b/src/test/java/io/jenkins/plugins/audit/listeners/UserPasswordLogListenerTest.java new file mode 100644 index 0000000..51fa5aa --- /dev/null +++ b/src/test/java/io/jenkins/plugins/audit/listeners/UserPasswordLogListenerTest.java @@ -0,0 +1,101 @@ +package io.jenkins.plugins.audit.listeners; + +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.Page; +import org.apache.commons.lang.StringUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.After; +import org.junit.Before; +import org.jvnet.hudson.test.Issue; +import org.xml.sax.SAXException; +import org.jvnet.hudson.test.JenkinsRule; +import org.apache.logging.log4j.core.LogEvent; +import org.jvnet.hudson.test.JenkinsRule.WebClient; +import org.apache.logging.log4j.test.appender.ListAppender; +import org.apache.logging.log4j.message.StructuredDataMessage; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.URL; + +import com.gargoylesoftware.htmlunit.html.HtmlPage; + +import static org.junit.Assert.*; + +import hudson.model.User; +import hudson.security.pages.SignupPage; +import hudson.security.HudsonPrivateSecurityRealm; + +public class UserPasswordLogListenerTest { + + private ListAppender app; + private WebClient client; + + private static void assertEventCount(final List events, final int expected) { + assertEquals("Incorrect number of events.", expected, events.size()); + } + + private static WebClient logout(final WebClient wc) throws IOException, SAXException { + wc.goTo("logout"); + return wc; + } + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Before + public void setup() throws Exception { + // user ID conformance check + Field field = HudsonPrivateSecurityRealm.class.getDeclaredField("ID_REGEX"); + field.setAccessible(true); + field.set(null, null); + + client = j.createWebClient(); + logout(client); + + app = ListAppender.getListAppender("AuditList").clear(); + } + + @After + public void teardown() { + app.clear(); + } + + @Issue("JENKINS-55694") + @Test + public void updateUserPassword() throws Exception { + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(true, false, null); + j.jenkins.setSecurityRealm(securityRealm); + + List events = app.getEvents(); + assertEventCount(events, 0); + + // new user account creation + SignupPage signup = new SignupPage(client.goTo("signup")); + signup.enterUsername("alice"); + signup.enterPassword("alice"); + signup.enterFullName(StringUtils.capitalize("alice user")); + HtmlPage p = signup.submit(j); + + // execute an http request to change a user's password from their config page + User alice = User.getById("alice", false); + URL configPage = client.createCrumbedUrl(alice.getUrl() + "/" + "configSubmit"); + String formData = "{\"fullName\": \"alice user\", \"description\": \"\", \"userProperty2\": {\"primaryViewName\": \"\"}, \"userProperty4\": {\"user.password\": \"admin\", \"$redact\": [\"user.password\", \"user.password2\"], \"user.password2\": \"admin\"}, \"userProperty5\": {\"authorizedKeys\": \"\"}, \"userProperty7\": {\"insensitiveSearch\": true}, \"core:apply\": \"true\"}"; + + WebRequest request = new WebRequest(configPage, HttpMethod.POST); + request.setAdditionalHeader("Content-Type", "application/x-www-form-urlencoded"); + request.setRequestBody("json=" + URLEncoder.encode(formData, StandardCharsets.UTF_8.name())); + Page page = client.getPage(request); + + // ensure user whose password was changed was in fact logged + StructuredDataMessage logMessage = (StructuredDataMessage) events.get(2).getMessage(); + assertTrue(logMessage.toString().contains("updatePassword")); + assertEventCount(events, 3); + } + +}