diff --git a/addOns/dev/dev.gradle.kts b/addOns/dev/dev.gradle.kts index 6e5dfb2459d..8ea69dd3e9f 100644 --- a/addOns/dev/dev.gradle.kts +++ b/addOns/dev/dev.gradle.kts @@ -7,6 +7,11 @@ zapAddOn { author.set("ZAP Dev Team") url.set("https://www.zaproxy.org/docs/desktop/addons/dev-add-on/") + bundle { + baseName.set("org.zaproxy.addon.dev.Messages") + prefix.set("dev") + } + dependencies { addOns { register("commonlib") { @@ -23,4 +28,9 @@ zapAddOn { dependencies { zapAddOn("network") zapAddOn("commonlib") + + compileOnly(libs.log4j.core) + + testImplementation(project(":testutils")) + testImplementation(libs.log4j.core) } diff --git a/addOns/dev/src/main/java/org/zaproxy/addon/dev/ExtensionDev.java b/addOns/dev/src/main/java/org/zaproxy/addon/dev/ExtensionDev.java index ff87aa791a8..1edfb5ceb4d 100644 --- a/addOns/dev/src/main/java/org/zaproxy/addon/dev/ExtensionDev.java +++ b/addOns/dev/src/main/java/org/zaproxy/addon/dev/ExtensionDev.java @@ -23,7 +23,9 @@ import org.parosproxy.paros.control.Control; import org.parosproxy.paros.extension.ExtensionAdaptor; import org.parosproxy.paros.extension.ExtensionHook; +import org.zaproxy.addon.dev.error.LoggedErrorsHandler; import org.zaproxy.addon.network.ExtensionNetwork; +import org.zaproxy.zap.view.ZapMenuItem; public class ExtensionDev extends ExtensionAdaptor { @@ -33,18 +35,24 @@ public class ExtensionDev extends ExtensionAdaptor { protected static final String DIRECTORY_NAME = "dev-add-on"; + private final LoggedErrorsHandler loggedErrorsHandler; + private TestProxyServer tutorialServer; private DevParam devParam; public ExtensionDev() { super(NAME); + + loggedErrorsHandler = new LoggedErrorsHandler(); } @Override public void hook(ExtensionHook extensionHook) { super.hook(extensionHook); + loggedErrorsHandler.hook(extensionHook); + extensionHook.addOptionsParamSet(this.getDevParam()); if (Constant.isDevMode()) { @@ -56,6 +64,15 @@ public void hook(ExtensionHook extensionHook) { .getExtension(ExtensionNetwork.class)); extensionHook.addApiImplementor(new DevApi()); } + + if (hasView() + && org.zaproxy.zap.extension.log4j.ExtensionLog4j.class.getAnnotation( + Deprecated.class) + != null) { + ZapMenuItem menuGarbageCollect = new ZapMenuItem("dev.tools.menu.gc"); + menuGarbageCollect.addActionListener(e -> Runtime.getRuntime().gc()); + extensionHook.getHookMenu().addToolsMenuItem(menuGarbageCollect); + } } public DevParam getDevParam() { @@ -77,6 +94,8 @@ public void unload() { if (tutorialServer != null) { tutorialServer.stop(); } + + loggedErrorsHandler.unload(); } @Override diff --git a/addOns/dev/src/main/java/org/zaproxy/addon/dev/error/LoggedErrorsHandler.java b/addOns/dev/src/main/java/org/zaproxy/addon/dev/error/LoggedErrorsHandler.java new file mode 100644 index 00000000000..dfc69dc585a --- /dev/null +++ b/addOns/dev/src/main/java/org/zaproxy/addon/dev/error/LoggedErrorsHandler.java @@ -0,0 +1,182 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2024 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.zaproxy.addon.dev.error; + +import java.awt.EventQueue; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; +import javax.swing.SwingUtilities; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.StringLayout; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.filter.LevelMatchFilter; +import org.apache.logging.log4j.core.layout.PatternLayout; +import org.parosproxy.paros.Constant; +import org.parosproxy.paros.control.Control.Mode; +import org.parosproxy.paros.extension.ExtensionHook; +import org.parosproxy.paros.extension.SessionChangedListener; +import org.parosproxy.paros.model.Session; +import org.parosproxy.paros.view.View; +import org.zaproxy.zap.utils.DisplayUtils; +import org.zaproxy.zap.view.ScanStatus; + +public class LoggedErrorsHandler { + + private final boolean loaded; + private ScanStatus scanStatus; + + public LoggedErrorsHandler() { + loaded = + org.zaproxy.zap.extension.log4j.ExtensionLog4j.class.getAnnotation(Deprecated.class) + != null + && Constant.isDevMode() + && View.isInitialised(); + + if (loaded) { + scanStatus = + new ScanStatus( + DisplayUtils.getScaledIcon( + getClass() + .getResource( + "/org/zaproxy/addon/dev/icons/fugue/bug.png")), + Constant.messages.getString("dev.error.icon.title")); + + LoggerContext.getContext() + .getConfiguration() + .getRootLogger() + .addAppender(new ErrorAppender(this::handleError), null, null); + + View.getSingleton() + .getMainFrame() + .getMainFooterPanel() + .addFooterToolbarRightLabel(scanStatus.getCountLabel()); + } + } + + public void hook(ExtensionHook extensionHook) { + if (!loaded) { + return; + } + + extensionHook.addSessionListener(new ResetCounterOnSessionChange(scanStatus)); + } + + public void unload() { + if (!loaded) { + return; + } + + LoggerContext.getContext() + .getConfiguration() + .getRootLogger() + .removeAppender(ErrorAppender.NAME); + + View.getSingleton() + .getMainFrame() + .getMainFooterPanel() + .removeFooterToolbarRightLabel(scanStatus.getCountLabel()); + } + + private void handleError(String message) { + if (!SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(() -> handleError(message)); + return; + } + + scanStatus.incScanCount(); + View.getSingleton().getOutputPanel().append(message); + } + + static class ErrorAppender extends AbstractAppender { + + private static final String NAME = "ZAP-ErrorAppender"; + + private static final Property[] NO_PROPERTIES = {}; + + private final Consumer logConsumer; + + ErrorAppender(Consumer logConsumer) { + super( + NAME, + LevelMatchFilter.newBuilder().setLevel(Level.ERROR).build(), + PatternLayout.newBuilder() + .withDisableAnsi(true) + .withCharset(StandardCharsets.UTF_8) + .withPattern("%m%n") + .build(), + true, + NO_PROPERTIES); + this.logConsumer = logConsumer; + start(); + } + + @Override + public void append(LogEvent event) { + logConsumer.accept(((StringLayout) getLayout()).toSerializable(event)); + } + } + + private static class ResetCounterOnSessionChange implements SessionChangedListener { + /** Keep track of errors logged while the session changes. */ + private int previousCount; + + /** Do not reset the counter if ZAP is starting. */ + private boolean starting; + + private ScanStatus scanStatus; + + public ResetCounterOnSessionChange(ScanStatus scanStatus) { + this.scanStatus = scanStatus; + this.starting = true; + } + + @Override + public void sessionAboutToChange(Session session) { + EventQueue.invokeLater(() -> previousCount = scanStatus.getScanCount()); + } + + @Override + public void sessionChanged(Session session) { + if (starting) { + starting = false; + return; + } + + EventQueue.invokeLater( + () -> { + scanStatus.setScanCount(scanStatus.getScanCount() - previousCount); + previousCount = 0; + }); + } + + @Override + public void sessionScopeChanged(Session session) { + // Nothing to do. + } + + @Override + public void sessionModeChanged(Mode mode) { + // Nothing to do. + } + } +} diff --git a/addOns/dev/src/main/resources/org/zaproxy/addon/dev/resources/Messages.properties b/addOns/dev/src/main/resources/org/zaproxy/addon/dev/Messages.properties similarity index 70% rename from addOns/dev/src/main/resources/org/zaproxy/addon/dev/resources/Messages.properties rename to addOns/dev/src/main/resources/org/zaproxy/addon/dev/Messages.properties index f2b3f973d69..4671d82b172 100644 --- a/addOns/dev/src/main/resources/org/zaproxy/addon/dev/resources/Messages.properties +++ b/addOns/dev/src/main/resources/org/zaproxy/addon/dev/Messages.properties @@ -2,3 +2,7 @@ dev.api.desc = Dev related API endpoints. dev.api.other.openapi = Provides the OpenAPI definition of the ZAP API, in YAML format. dev.desc = An add-on to help with development of ZAP + +dev.error.icon.title = Errors + +dev.tools.menu.gc = Run the Garbage Collector diff --git a/addOns/dev/src/main/resources/org/zaproxy/addon/dev/icons/fugue/bug.png b/addOns/dev/src/main/resources/org/zaproxy/addon/dev/icons/fugue/bug.png new file mode 100644 index 00000000000..cb0ec4bd86f Binary files /dev/null and b/addOns/dev/src/main/resources/org/zaproxy/addon/dev/icons/fugue/bug.png differ diff --git a/addOns/dev/src/test/java/org/zaproxy/zap/extension/log4j/LoggedErrorsHandlerUnitTest.java b/addOns/dev/src/test/java/org/zaproxy/zap/extension/log4j/LoggedErrorsHandlerUnitTest.java new file mode 100644 index 00000000000..18a10e79fa7 --- /dev/null +++ b/addOns/dev/src/test/java/org/zaproxy/zap/extension/log4j/LoggedErrorsHandlerUnitTest.java @@ -0,0 +1,99 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2024 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.zaproxy.zap.extension.log4j; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.startsWith; + +import java.util.ArrayList; +import java.util.List; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configurator; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.zaproxy.zap.extension.log4j.ExtensionLog4j.ErrorAppender; + +/** Unit test for {@link LoggedErrorsHandler}. */ +class LoggedErrorsHandlerUnitTest { + + /** Unit test for {@link ErrorAppender}. */ + static class ErrorAppenderUnitTest { + + private Logger logger; + private List logEvents; + + private ErrorAppender errorAppender; + + @BeforeEach + void setup() { + logEvents = new ArrayList<>(); + errorAppender = new ErrorAppender(logEvents::add); + + LoggerContext context = LoggerContext.getContext(); + LoggerConfig rootLoggerconfig = context.getConfiguration().getRootLogger(); + rootLoggerconfig + .getAppenders() + .values() + .forEach(context.getRootLogger()::removeAppender); + rootLoggerconfig.addAppender(errorAppender, null, null); + rootLoggerconfig.setLevel(Level.ALL); + context.updateLoggers(); + + logger = LogManager.getLogger(ErrorAppenderUnitTest.class); + } + + @AfterEach + void cleanup() throws Exception { + Configurator.reconfigure(getClass().getResource("/log4j2-test.properties").toURI()); + } + + @Test + void shouldLogError() { + // Given + Level level = Level.ERROR; + String message = "Log Message"; + // When + logger.log(level, message); + // Then + assertThat(logEvents, hasSize(1)); + assertThat(logEvents.get(0), startsWith(message)); + } + + @ParameterizedTest + @ValueSource(strings = {"FATAL", "WARN", "INFO", "DEBUG", "TRACE"}) + void shouldIgnoreLogEventsOtherThanError(String levelName) { + // Given + Level level = Level.getLevel(levelName); + String message = "Log Message"; + // When + logger.log(level, message); + // Then + assertThat(logEvents, hasSize(0)); + } + } +}