diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/ExtensionClientIntegration.java b/addOns/client/src/main/java/org/zaproxy/addon/client/ExtensionClientIntegration.java index ea5ce5ba3a4..c5737885f6b 100644 --- a/addOns/client/src/main/java/org/zaproxy/addon/client/ExtensionClientIntegration.java +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/ExtensionClientIntegration.java @@ -23,8 +23,10 @@ import java.awt.event.InputEvent; import java.awt.event.KeyEvent; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; +import java.io.Writer; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -57,6 +59,7 @@ import org.parosproxy.paros.view.View; import org.zaproxy.addon.client.impl.ClientZestRecorder; import org.zaproxy.addon.client.internal.ClientMap; +import org.zaproxy.addon.client.internal.ClientMapWriter; import org.zaproxy.addon.client.internal.ClientNode; import org.zaproxy.addon.client.internal.ClientSideComponent; import org.zaproxy.addon.client.internal.ClientSideDetails; @@ -82,6 +85,7 @@ import org.zaproxy.addon.client.ui.PopupMenuClientHistoryCopy; import org.zaproxy.addon.client.ui.PopupMenuClientOpenInBrowser; import org.zaproxy.addon.client.ui.PopupMenuClientShowInSites; +import org.zaproxy.addon.client.ui.PopupMenuExportClientMap; import org.zaproxy.addon.commonlib.ExtensionCommonlib; import org.zaproxy.addon.network.ExtensionNetwork; import org.zaproxy.zap.ZAP; @@ -291,6 +295,13 @@ public void hook(ExtensionHook extensionHook) { .getMainFrame() .getMainFooterPanel() .addFooterToolbarRightComponent(pscanStatus.getCountLabel()); + + extensionHook + .getHookMenu() + .addPopupMenuItem( + new PopupMenuExportClientMap( + Constant.messages.getString("client.tree.popup.export.menu"), + this)); } } @@ -863,4 +874,27 @@ public void sessionModeChanged(Mode mode) { } } } + + public ClientMap getClientTree() { + return clientTree; + } + + public void exportClientMap(String path) { + File file = new File(path); + Writer fileWriter = null; + + try { + fileWriter = new FileWriter(file, false); + } catch (IOException e) { + LOGGER.warn( + "Failed to create file writer for: {}, while exporting Client Map", path, e); + } + + try { + ClientMapWriter.exportClientMap(fileWriter, getClientTree()); + } catch (IOException e) { + LOGGER.warn("Failure while exporting Client Map: {}", path, e); + } + LOGGER.info("Wrote: {}", file.getAbsolutePath()); + } } diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMapWriter.java b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMapWriter.java new file mode 100644 index 00000000000..93b448cfb66 --- /dev/null +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMapWriter.java @@ -0,0 +1,202 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2025 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.client.internal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.parosproxy.paros.control.Control; +import org.zaproxy.addon.client.ExtensionClientIntegration; +import org.zaproxy.addon.client.ui.PopupMenuExportClientMap; +import org.zaproxy.zap.utils.Stats; + +public final class ClientMapWriter { + + private static final Logger LOGGER = LogManager.getLogger(ClientMapWriter.class); + + private static final String NODE_KEY = "node"; + private static final String ROOT_NODE_NAME = "ClientMap"; + private static final String CHILDREN_KEY = "children"; + private static final String STORAGE_KEY = "isStorage"; + private static final String VISITED_KEY = "visited"; + + private static final List COMPONENT_TYPES_TO_SKIP_LC = + List.of( + ClientSideComponent.REDIRECT.toLowerCase(Locale.ROOT), + ClientSideComponent.CONTENT_LOADED.toLowerCase(Locale.ROOT)); + + private static ExtensionClientIntegration extension; + + ClientMapWriter() {} + + public static void exportClientMap(File file) throws IOException { + try (FileWriter fw = new FileWriter(file, false)) { + extension = + Control.getSingleton() + .getExtensionLoader() + .getExtension(ExtensionClientIntegration.class); + exportClientMap(fw); + } + } + + public static void exportClientMap(Writer fw) throws IOException { + exportClientMap(fw, extension.getClientTree()); + } + + public static void exportClientMap(Writer fw, ClientMap clientMap) throws IOException { + try (BufferedWriter bw = new BufferedWriter(fw)) { + outputNode(bw, clientMap.getRoot(), 0); + } + } + + private static boolean outputKV( + Writer fw, String indent, boolean first, String key, Object value) throws IOException { + fw.write(indent); + if (first) { + fw.write("- "); + } else { + fw.write(" "); + } + fw.write(key); + fw.write(": "); + ObjectMapper mapper = + new ObjectMapper( + new YAMLFactory() + .enable(Feature.LITERAL_BLOCK_STYLE) + .disable(Feature.WRITE_DOC_START_MARKER)); + // For some reason the disable start marker doesn't seem to work + String output = mapper.writeValueAsString(value).replace("--- ", ""); + fw.write(output); + return false; + } + + private static void outputNode(Writer fw, ClientNode node, int level) throws IOException { + if (node.isStorage()) { + // Skip storage nodes in the tree + // Those details are represented as components of their source + return; + } + // We could create a set of data structures and use jackson, but the format is very + // simple and this is much more memory efficient - it still uses jackson for value + // output + String indent = " ".repeat(level * 2); + + outputKV( + fw, + indent, + true, + NODE_KEY, + level == 0 ? ROOT_NODE_NAME : node.getUserObject().getName()); + + if (node.getUserObject().isStorage()) { + outputKV(fw, indent, false, STORAGE_KEY, node.getUserObject().isStorage()); + } + if (!node.getUserObject().isVisited()) { + outputKV(fw, indent, false, VISITED_KEY, node.getUserObject().isVisited()); + } + if (node.getUserObject().getComponents() != null + && !node.getUserObject().getComponents().isEmpty()) { + for (ClientSideComponent component : node.getUserObject().getComponents()) { + if (component.getTypeForDisplay().equalsIgnoreCase(ClientSideComponent.REDIRECT)) { + outputKV( + fw, + indent, + false, + ClientSideComponent.REDIRECT.toLowerCase(Locale.ROOT), + component.getHref()); + } + if (component + .getTypeForDisplay() + .equalsIgnoreCase(ClientSideComponent.CONTENT_LOADED)) { + outputKV(fw, indent, false, "contentLoaded", true); + } + } + indent = outputComponents(fw, node.getUserObject().getComponents(), level, indent); + } + + Stats.incCounter(PopupMenuExportClientMap.STATS_EXPORT_CLIENTMAP + ".node"); + + if (node.getChildCount() > 0) { + fw.write(indent); + fw.write(" "); + fw.write(CHILDREN_KEY); + fw.write(":"); + fw.write('\n'); + node.children() + .asIterator() + .forEachRemaining( + c -> { + try { + outputNode(fw, (ClientNode) c, level + 1); + } catch (IOException e) { + LOGGER.error(e.getMessage(), e); + } + }); + } + } + + private static String outputComponents( + Writer fw, Set components, int level, String indent) + throws IOException { + fw.write(indent + " "); + fw.write("components:\n"); + indent = " ".repeat((level + 1) * 2); + boolean first = true; + + LinkedHashSet sortedComponents = + components.stream().sorted().collect(Collectors.toCollection(LinkedHashSet::new)); + for (ClientSideComponent component : sortedComponents) { + if ((component.getTypeForDisplay() != null + && COMPONENT_TYPES_TO_SKIP_LC.contains( + component.getTypeForDisplay().toLowerCase(Locale.ROOT)) + || (component.getType() != null + && "nodeAdded".equalsIgnoreCase(component.getType())))) { + continue; + } + first = outputKV(fw, indent, first, "nodeType", component.getTypeForDisplay()); + first = outputKV(fw, indent, first, "href", component.getHref()); + if (!component.isStorageEvent()) { + first = outputKV(fw, indent, first, "text", component.getText()); + } + first = outputKV(fw, indent, first, "id", component.getId()); + first = outputKV(fw, indent, first, "tagName", component.getTagName()); + first = outputKV(fw, indent, first, "tagType", component.getTagType()); + if (component.getFormId() != -1) { + first = outputKV(fw, indent, first, "formId", component.getFormId()); + } + first = outputKV(fw, indent, first, "type", component.getType()); + outputKV(fw, indent, first, "isStorageEvent", component.isStorageEvent()); + first = true; + } + return indent; + } +} diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideComponent.java b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideComponent.java index fa9b66df809..4ab1bb0916f 100644 --- a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideComponent.java +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideComponent.java @@ -20,22 +20,25 @@ package org.zaproxy.addon.client.internal; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import lombok.AllArgsConstructor; import lombok.Getter; import net.sf.json.JSONObject; import org.parosproxy.paros.Constant; +import org.zaproxy.addon.client.ClientUtils; import org.zaproxy.addon.client.ExtensionClientIntegration; @Getter @AllArgsConstructor -public class ClientSideComponent { - +public class ClientSideComponent implements Comparable { public static final String REDIRECT = "Redirect"; public static final String CONTENT_LOADED = "ContentLoaded"; + private static final List STORAGE_TYPES = + List.of("Cookies", ClientUtils.LOCAL_STORAGE, ClientUtils.SESSION_STORAGE); private final Map data; private String tagName; @@ -99,12 +102,7 @@ public boolean isStorageEvent() { if (type == null) { return false; } - switch (type) { - case "Cookies", "localStorage", "sessionStorage": - return true; - default: - return false; - } + return STORAGE_TYPES.contains(this.type); } @Override @@ -124,4 +122,57 @@ public boolean equals(Object obj) { && Objects.equals(tagName, other.tagName) && Objects.equals(text, other.text); } + + @Override + public int compareTo(ClientSideComponent other) { + int result = 0; + result = stringCompare(this.getTypeForDisplay(), other.getTypeForDisplay()); + if (result != 0) { + return result; + } + // Types are the same, check next relevant property(ies) + result = stringCompare(this.href, other.href); + if (result != 0) { + return result; + } + result = stringCompare(this.text, other.text); + if (result != 0) { + return result; + } + result = stringCompare(this.id, other.id); + if (result != 0) { + return result; + } + result = stringCompare(this.tagName, other.tagName); + if (result != 0) { + return result; + } + result = stringCompare(this.tagType, other.tagType); + if (result != 0) { + return result; + } + if (this.formId > other.formId) { + return 1; + } else if (this.formId < other.formId) { + return -1; + } + return 0; + } + + private static int stringCompare(String one, String two) { + if (one == null || two == null) { + return nullCompare(one, two); + } + return one.compareTo(two); + } + + private static int nullCompare(Object here, Object other) { + if (here == null && other == null) { + return 0; + } + if (here == null) { + return -1; + } + return 1; + } } diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/ui/PopupMenuExportClientMap.java b/addOns/client/src/main/java/org/zaproxy/addon/client/ui/PopupMenuExportClientMap.java new file mode 100644 index 00000000000..cc31a5a6df4 --- /dev/null +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/ui/PopupMenuExportClientMap.java @@ -0,0 +1,142 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2010 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.client.ui; + +import java.awt.Component; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.util.Locale; +import javax.swing.JFileChooser; +import javax.swing.filechooser.FileNameExtensionFilter; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.parosproxy.paros.Constant; +import org.parosproxy.paros.extension.ExtensionPopupMenuItem; +import org.zaproxy.addon.client.ExtensionClientIntegration; +import org.zaproxy.addon.client.internal.ClientMapWriter; +import org.zaproxy.addon.commonlib.MenuWeights; +import org.zaproxy.zap.utils.Stats; +import org.zaproxy.zap.view.widgets.WritableFileChooser; + +@SuppressWarnings("serial") +public class PopupMenuExportClientMap extends ExtensionPopupMenuItem { + + private static final long serialVersionUID = 1L; + private static ExtensionClientIntegration extension; + private static final String YAML_EXT = ".yaml"; + private static final Logger LOGGER = LogManager.getLogger(PopupMenuExportClientMap.class); + public static final String STATS_EXPORT_CLIENTMAP = + ExtensionClientIntegration.PREFIX + ".export.clientmap"; + + /** + * Constructs a {@code PopupMenuExportClientMap} with the given label and extension. + * + * @param label the label of the menu item + * @param extension the extension to access the model and view, must not be {@code null}. + * @throws IllegalArgumentException if the given {@code extension} is {@code null}. + */ + public PopupMenuExportClientMap(String label, ExtensionClientIntegration extension) { + super(label); + + if (extension == null) { + throw new IllegalArgumentException("Parameter extension must not be null."); + } + PopupMenuExportClientMap.extension = extension; + + this.addActionListener(e -> performAction()); + } + + @Override + public boolean isEnableForComponent(Component invoker) { + if ("treeClient".equals(invoker.getName())) { + return true; + } + return false; + } + + protected void performAction() { + File file = getOutputFile(); + if (file == null) { + return; + } + + writeClientMap(file); + Stats.incCounter(STATS_EXPORT_CLIENTMAP); + } + + protected void writeClientMap(File file) { + + try (BufferedWriter fw = new BufferedWriter(new FileWriter(file, false))) { + + ClientMapWriter.exportClientMap(fw, extension.getClientTree()); + + } catch (Exception e1) { + LOGGER.warn("An error occurred while exporting the Client Map:", e1); + extension + .getView() + .showWarningDialog( + Constant.messages.getString( + "client.tree.popup.export.error", file.getAbsolutePath())); + } + } + + protected File getOutputFile() { + FileNameExtensionFilter yamlFilesFilter = + new FileNameExtensionFilter( + Constant.messages.getString("client.tree.popup.export.format.yaml"), + "yaml"); + WritableFileChooser chooser = + new WritableFileChooser(extension.getModel().getOptionsParam().getUserDirectory()) { + + private static final long serialVersionUID = 1L; + + @Override + public void approveSelection() { + File file = getSelectedFile(); + if (file != null) { + String filePath = file.getAbsolutePath(); + + setSelectedFile( + new File( + filePath.toLowerCase(Locale.ROOT).endsWith(YAML_EXT) + ? filePath + : filePath + YAML_EXT)); + } + + super.approveSelection(); + } + }; + + chooser.addChoosableFileFilter(yamlFilesFilter); + chooser.setFileFilter(yamlFilesFilter); + + int rc = chooser.showSaveDialog(extension.getView().getMainFrame()); + if (rc == JFileChooser.APPROVE_OPTION) { + return chooser.getSelectedFile(); + } + return null; + } + + @Override + public int getWeight() { + return MenuWeights.MENU_CONTEXT_EXPORT_URLS_WEIGHT; + } +} diff --git a/addOns/client/src/main/resources/org/zaproxy/addon/client/resources/Messages.properties b/addOns/client/src/main/resources/org/zaproxy/addon/client/resources/Messages.properties index 992846a27f6..2c2ac5a2897 100644 --- a/addOns/client/src/main/resources/org/zaproxy/addon/client/resources/Messages.properties +++ b/addOns/client/src/main/resources/org/zaproxy/addon/client/resources/Messages.properties @@ -170,6 +170,9 @@ client.tree.popup.browser = Open in Browser... client.tree.popup.copyurls = Copy URLs client.tree.popup.delete = Delete... client.tree.popup.delete.confirm = Are you sure you want to delete these Client Nodes? +client.tree.popup.export.error = Error saving file to {0} +client.tree.popup.export.format.yaml = .yaml +client.tree.popup.export.menu = Export Client Map client.tree.popup.sites = Show in Sites Tree client.tree.popup.spider = Client Spider... client.tree.title = Client Map diff --git a/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapWriterUnitTest.java b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapWriterUnitTest.java new file mode 100644 index 00000000000..0661d73a42b --- /dev/null +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapWriterUnitTest.java @@ -0,0 +1,388 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2025 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.client.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.parosproxy.paros.model.Session; +import org.zaproxy.addon.client.ClientUtils; +import org.zaproxy.addon.client.ExtensionClientIntegration; +import org.zaproxy.zap.ZAP; +import org.zaproxy.zap.model.StandardParameterParser; +import org.zaproxy.zap.testutils.TestUtils; + +class ClientMapWriterUnitTest extends TestUtils { + + private ClientNode root; + ClientMap map; + + @BeforeAll + static void init() { + mockMessages(new ExtensionClientIntegration()); + } + + @BeforeEach + void setup() { + Session session = mock(Session.class); + StandardParameterParser ssp = new StandardParameterParser(); + given(session.getUrlParamParser(any(String.class))).willReturn(ssp); + root = new ClientNode(new ClientSideDetails("Root", ""), session); + map = new ClientMap(root); + } + + @AfterEach + void tearDown() { + ZAP.getEventBus().unregisterPublisher(map); + } + + @Test + void shouldExportExpectedSortedUrls() throws IOException { + // Given + String url = "https://example.com/"; + map.getOrAddNode(url, true, false); + map.getOrAddNode(url + "zzz", true, false); + map.getOrAddNode(url + "aaa", true, false); + map.getOrAddNode(url + "zaa", true, false); + Writer stringWriter = new StringWriter(); + // When + ClientMapWriter.exportClientMap(stringWriter, map); + // Then + String output = stringWriter.toString(); + assertThat( + output, + is( + equalTo( + """ + - node: "ClientMap" + visited: false + children: + - node: "https://example.com" + visited: false + children: + - node: "/" + - node: "aaa" + - node: "zaa" + - node: "zzz" + """))); + } + + @Test + void shouldExportExpectedSortedUrlsAndComponents() throws IOException { + // Given + String zooUrl = "https://zoo.animal"; + ClientNode zoo = map.getOrAddNode(zooUrl, true, false); + // localStorage + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "some-sid", + "", + "https://zoo.animal", + "foo", + ClientUtils.LOCAL_STORAGE, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "a-sid", + "", + "https://zoo.animal", + "foo", + ClientUtils.LOCAL_STORAGE, + "", + -1)); + // sessionStorage + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "z-bar", + "", + "https://zoo.animal", + "fooz", + ClientUtils.SESSION_STORAGE, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "z-bar", + "", + "https://zoo.animal", + "fooa", + ClientUtils.SESSION_STORAGE, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "a-bar", + "", + "https://zoo.animal", + "fooa", + ClientUtils.SESSION_STORAGE, + "", + -1)); + // Cookies + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + zooUrl, + "aNotDisplayed", + "Cookies", + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + zooUrl, + "zNotDisplayed", + "Cookies", + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "fooz", + "", + zooUrl, + "zNotDisplayed", + "Cookies", + "", + -1)); + // Links + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "A", + "foo-logo", + "", + "https://foo.example.com/", + "", + "", + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "A", + "", + "", + "https://foo.example.com/", + "Foo Example", + "", + "", + -1)); + // Buttons + zoo.getUserObject() + .addComponent( + new ClientSideComponent(Map.of(), "BUTTON", "", "", "", "", "", "", -1)); + // Inputs + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), "INPUT", "", "", "", "", "Search", "", 0)); + // Forms + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), "FORM", "head-search", "", "", "", "", "", 0)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), "FORM", "foot-search", "", "", "", "", "", 1)); + map.getOrAddNode("https://foo.bar", true, false); + map.getOrAddNode("https://1acme.com", true, false); + + String url = "https://example.com/"; + map.getOrAddNode(url, true, false); + map.getOrAddNode(url + "zzz", true, false); + map.getOrAddNode(url + "aaa", true, false); + map.getOrAddNode(url + "zaa", true, false); + + Writer stringWriter = new StringWriter(); + // When + ClientMapWriter.exportClientMap(stringWriter, map); + // Then + String output = stringWriter.toString(); + assertThat( + output, + is( + equalTo( + """ + - node: "ClientMap" + visited: false + children: + - node: "https://1acme.com" + - node: "https://example.com" + visited: false + children: + - node: "/" + - node: "aaa" + - node: "zaa" + - node: "zzz" + - node: "https://foo.bar" + - node: "https://zoo.animal" + components: + - nodeType: "Button" + href: "" + text: "" + id: "" + tagName: "BUTTON" + tagType: "" + type: "" + isStorageEvent: false + - nodeType: "Cookies" + href: "https://zoo.animal" + id: "foo" + tagName: "" + tagType: "" + type: "Cookies" + isStorageEvent: true + - nodeType: "Cookies" + href: "https://zoo.animal" + id: "foo" + tagName: "" + tagType: "" + type: "Cookies" + isStorageEvent: true + - nodeType: "Cookies" + href: "https://zoo.animal" + id: "fooz" + tagName: "" + tagType: "" + type: "Cookies" + isStorageEvent: true + - nodeType: "FORM" + href: "" + text: "" + id: "foot-search" + tagName: "FORM" + tagType: "" + formId: 1 + type: "" + isStorageEvent: false + - nodeType: "FORM" + href: "" + text: "" + id: "head-search" + tagName: "FORM" + tagType: "" + formId: 0 + type: "" + isStorageEvent: false + - nodeType: "Input" + href: "" + text: "" + id: "" + tagName: "INPUT" + tagType: "" + formId: 0 + type: "Search" + isStorageEvent: false + - nodeType: "Link" + href: "https://foo.example.com/" + text: "" + id: "foo-logo" + tagName: "A" + tagType: "" + type: "" + isStorageEvent: false + - nodeType: "Link" + href: "https://foo.example.com/" + text: "Foo Example" + id: "" + tagName: "A" + tagType: "" + type: "" + isStorageEvent: false + - nodeType: "Local Storage" + href: "https://zoo.animal" + id: "a-sid" + tagName: "" + tagType: "" + type: "localStorage" + isStorageEvent: true + - nodeType: "Local Storage" + href: "https://zoo.animal" + id: "some-sid" + tagName: "" + tagType: "" + type: "localStorage" + isStorageEvent: true + - nodeType: "Session Storage" + href: "https://zoo.animal" + id: "a-bar" + tagName: "" + tagType: "" + type: "sessionStorage" + isStorageEvent: true + - nodeType: "Session Storage" + href: "https://zoo.animal" + id: "z-bar" + tagName: "" + tagType: "" + type: "sessionStorage" + isStorageEvent: true + - nodeType: "Session Storage" + href: "https://zoo.animal" + id: "z-bar" + tagName: "" + tagType: "" + type: "sessionStorage" + isStorageEvent: true + """))); + } +} diff --git a/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponenetUnitTest.java b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponenetUnitTest.java new file mode 100644 index 00000000000..1f7483e7321 --- /dev/null +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponenetUnitTest.java @@ -0,0 +1,146 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2025 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.client.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.zaproxy.addon.client.ExtensionClientIntegration; +import org.zaproxy.zap.testutils.TestUtils; + +/* Unit Tests for {@code ClientSideComponenetUnitTest} + * SoretedSets are used for compareTo testing + */ +class ClientSideComponenetUnitTest extends TestUtils { + + private static final String EXAMPLE_URL = "https://example.com"; + + @BeforeAll + static void init() { + mockMessages(new ExtensionClientIntegration()); + } + + /* zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", // tagName + "fooz", // id + "", // parentUrl + zooUrl, // href + "zShould be ignored", // text + "Cookies", // type + "", // tagType + -1 // formId + )); */ + + // typeForDisplay, href, text, id, tagName, tagType, formId + @Test + void shouldOrderByTypeForDisplayThenHrefThenTextSameHrefs() { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), "", "foo", "", EXAMPLE_URL, "zNotDisplayed", "Cookies", "", -1); + ClientSideComponent two = + new ClientSideComponent(Map.of(), "A", "foo", "", EXAMPLE_URL, "zLink", "", "", -1); + ClientSideComponent three = + new ClientSideComponent( + Map.of(), "", "foo", "", EXAMPLE_URL, "aNotDisplayed", "Cookies", "", -1); + ClientSideComponent four = + new ClientSideComponent(Map.of(), "A", "foo", "", EXAMPLE_URL, "aLink", "", "", -1); + // When + SortedSet sortedComponents = + new TreeSet<>(Set.of(one, two, three, four)); + // Then + assertThat(sortedComponents, contains(three, one, four, two)); + } + + @Test + void shouldOrderByTypeForDisplayThenHrefThenTextDifferentHrefs() { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), "", "foo", "", EXAMPLE_URL, "zNotDisplayed", "Cookies", "", -1); + ClientSideComponent two = + new ClientSideComponent(Map.of(), "A", "foo", "", EXAMPLE_URL, "zLink", "", "", -1); + ClientSideComponent three = + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + "https://zoo.com", + "aNotDisplayed", + "Cookies", + "", + -1); + ClientSideComponent four = + new ClientSideComponent(Map.of(), "A", "foo", "", EXAMPLE_URL, "aLink", "", "", -1); + // When + SortedSet sortedComponents = + new TreeSet<>(Set.of(one, two, four, three)); + // Then + assertThat(sortedComponents, contains(one, three, four, two)); + } + + private static Stream getPathArguments() { + // The zeroth values should become the last when sorted + return Stream.of( + // Alpha + Arguments.of(List.of("/golf", "/a", "/b", "/c")), + // Length + Arguments.of(List.of("/aaaa", "/a", "/aa", "/aaa")), + // gold before golf + Arguments.of(List.of("/golf", "/a", "/b", "/gold")), + // Caps then length + Arguments.of(List.of("/aaa", "/A", "/a", "/aa"))); + } + + @ParameterizedTest + @MethodSource("getPathArguments") + void shouldSortSameTypesOnHrefFirst(List paths) { + // Given + ClientSideComponent zero = getComponentWithVariedPath(paths.get(0)); + ClientSideComponent one = getComponentWithVariedPath(paths.get(1)); + ClientSideComponent two = getComponentWithVariedPath(paths.get(2)); + ClientSideComponent three = getComponentWithVariedPath(paths.get(3)); + // When + SortedSet sortedComponents = + new TreeSet<>(Set.of(two, one, zero, three)); + // Then + assertThat(sortedComponents, contains(one, two, three, zero)); + } + + private static ClientSideComponent getComponentWithVariedPath(String pathPart) { + return new ClientSideComponent( + Map.of(), "A", "foo", "", EXAMPLE_URL + pathPart, "aLink", "", "", -1); + } +}