From e1ada6bcaac645e2306b0effa5c0b16a14aa0da5 Mon Sep 17 00:00:00 2001 From: kingthorin Date: Fri, 20 Dec 2024 10:07:47 -0500 Subject: [PATCH] client: Export Client Map Signed-off-by: kingthorin --- addOns/client/CHANGELOG.md | 3 + .../client/ExtensionClientIntegration.java | 33 ++ .../addon/client/internal/ClientMap.java | 4 +- .../client/internal/ClientMapWriter.java | 184 +++++++ .../client/internal/ClientSideComponent.java | 183 ++++++- .../client/ui/PopupMenuExportClientMap.java | 114 +++++ .../resources/help/contents/client.html | 4 + .../client/resources/Messages.properties | 7 + .../internal/ClientMapWriterUnitTest.java | 395 +++++++++++++++ .../internal/ClientSideComponentUnitTest.java | 463 ++++++++++++++++++ .../client/spider/ClientSpiderUnitTest.java | 2 +- 11 files changed, 1375 insertions(+), 17 deletions(-) create mode 100644 addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMapWriter.java create mode 100644 addOns/client/src/main/java/org/zaproxy/addon/client/ui/PopupMenuExportClientMap.java create mode 100644 addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapWriterUnitTest.java create mode 100644 addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponentUnitTest.java diff --git a/addOns/client/CHANGELOG.md b/addOns/client/CHANGELOG.md index aee7d3065c..cd6be052d3 100644 --- a/addOns/client/CHANGELOG.md +++ b/addOns/client/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Fix concurrency issue with page components which could lead to exceptions in the GUI. +### Added +- A context menu allowing users to Export Client Map. + ## [0.10.0] - 2025-01-10 ### Changed - Update minimum ZAP version to 2.16.0. 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 04ad5967c7..c788375c79 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,11 @@ 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.UncheckedIOException; +import java.io.Writer; import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -57,6 +60,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 +86,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; @@ -97,6 +102,7 @@ import org.zaproxy.zap.model.Target; import org.zaproxy.zap.users.User; import org.zaproxy.zap.utils.DisplayUtils; +import org.zaproxy.zap.utils.Stats; import org.zaproxy.zap.utils.ThreadUtils; import org.zaproxy.zap.view.ScanStatus; import org.zaproxy.zap.view.ZapMenuItem; @@ -122,6 +128,7 @@ public class ExtensionClientIntegration extends ExtensionAdaptor { ExtensionHistory.class, ExtensionNetwork.class, ExtensionSelenium.class); + private static final String STATS_EXPORT_CLIENTMAP = PREFIX + ".export.clientmap"; private ClientMap clientTree; private ClientMapPanel clientMapPanel; @@ -291,6 +298,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)); } } @@ -867,4 +881,23 @@ public void sessionModeChanged(Mode mode) { } } } + + public void exportClientMap(String path) { + File file = new File(path); + try (Writer fileWriter = new FileWriter(file, false)) { + ClientMapWriter.exportClientMap(fileWriter, clientTree); + } catch (IOException | UncheckedIOException e) { + LOGGER.warn( + "An error occurred while exporting the Client Map: {}", + file.getAbsolutePath(), + e); + if (hasView()) { + this.getView() + .showWarningDialog( + Constant.messages.getString( + "client.tree.export.error", file.getAbsolutePath())); + } + } + Stats.incCounter(STATS_EXPORT_CLIENTMAP); + } } diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMap.java b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMap.java index a5b139aa54..89c9671aed 100644 --- a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMap.java +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMap.java @@ -190,7 +190,7 @@ public ClientNode setRedirect(String originalUrl, String redirectedUrl) { originalUrl, redirectedUrl, ClientSideComponent.REDIRECT, - null, + ClientSideComponent.Type.REDIRECT, null, -1)); return node; @@ -225,7 +225,7 @@ public ClientNode setContentLoaded(String url) { null, null, ClientSideComponent.CONTENT_LOADED, - null, + ClientSideComponent.Type.CONTENT_LOADED, null, -1)); 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 0000000000..fe87dbb680 --- /dev/null +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMapWriter.java @@ -0,0 +1,184 @@ +/* + * 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.IOException; +import java.io.UncheckedIOException; +import java.io.Writer; +import java.util.List; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import org.zaproxy.addon.client.ExtensionClientIntegration; +import org.zaproxy.zap.utils.Stats; + +public final class ClientMapWriter { + + private static final String CHILDREN_KEY = "children"; + private static final String COMPONENTS_KEY = "components"; + private static final String FORM_ID_KEY = "formId"; + private static final String HREF_KEY = "href"; + private static final String ID_KEY = "id"; + private static final String NODE_TYPE_KEY = "nodeType"; + private static final String NODE_KEY = "node"; + private static final String ROOT_NODE_NAME = "ClientMap"; + private static final String STORAGE_EVENT_KEY = "storageEvent"; + private static final String STORAGE_KEY = "storage"; + private static final String TAG_NAME_KEY = "tagName"; + private static final String TAG_TYPE_KEY = "tagType"; + private static final String TEXT_KEY = "text"; + private static final String VISITED_KEY = "visited"; + + private static final List COMPONENT_TYPES_TO_SKIP = + List.of(ClientSideComponent.Type.REDIRECT, ClientSideComponent.Type.CONTENT_LOADED); + + ClientMapWriter() {} + + 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 { + if (value == null) { + return first; + } + 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 - 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.getType() == ClientSideComponent.Type.REDIRECT) { + outputKV( + fw, indent, false, component.getType().getLabel(), component.getHref()); + } else if (component.getType() == ClientSideComponent.Type.CONTENT_LOADED) { + outputKV(fw, indent, false, component.getType().getLabel(), true); + } + } + synchronized (node.getUserObject().getComponents()) { + indent = outputComponents(fw, node.getUserObject().getComponents(), level, indent); + } + } + + Stats.incCounter(ExtensionClientIntegration.PREFIX + ".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) { + throw new UncheckedIOException(e); + } + }); + } + } + + private static String outputComponents( + Writer fw, Set components, int level, String indent) + throws IOException { + fw.write(indent); + fw.write(" "); + fw.write(COMPONENTS_KEY); + fw.write(":\n"); + + indent = " ".repeat((level + 1) * 2); + + SortedSet sortedComponents = new TreeSet<>(components); + + for (ClientSideComponent component : sortedComponents) { + boolean first = true; + if ((component.getType() != null + && COMPONENT_TYPES_TO_SKIP.contains(component.getType()))) { + continue; + } + first = outputKV(fw, indent, first, NODE_TYPE_KEY, component.getType().getLabel()); + String href = + component.getHref() == null ? component.getParentUrl() : component.getHref(); + first = outputKV(fw, indent, first, HREF_KEY, href); + if (!component.isStorageEvent()) { + first = outputKV(fw, indent, first, TEXT_KEY, component.getText()); + } + first = outputKV(fw, indent, first, ID_KEY, component.getId()); + first = outputKV(fw, indent, first, TAG_NAME_KEY, component.getTagName()); + first = outputKV(fw, indent, first, TAG_TYPE_KEY, component.getTagType()); + if (component.getFormId() != -1) { + first = outputKV(fw, indent, first, FORM_ID_KEY, component.getFormId()); + } + if (component.isStorageEvent()) { + outputKV(fw, indent, first, STORAGE_EVENT_KEY, component.isStorageEvent()); + } + } + 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 fa9b66df80..a994256cd7 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 @@ -24,18 +24,117 @@ import java.util.Objects; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NonNull; import net.sf.json.JSONObject; import org.parosproxy.paros.Constant; 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"; + public enum Type { + LINK( + "Link", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".components.type.link"), + "link"), + BUTTON( + "Button", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".components.type.button"), + "button"), + INPUT( + "Input", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".components.type.input"), + "input"), + FORM( + "Form", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".components.type.form"), + "form"), + COOKIES( + "Cookies", + Constant.messages.getString(ExtensionClientIntegration.PREFIX + ".type.Cookies"), + "Cookies"), + LOCAL_STORAGE( + "Local Storage", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".type.localStorage"), + "localStorage"), + SESSION_STORAGE( + "Session Storage", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".type.sessionStorage"), + "sessionStorage"), + REDIRECT( + "Redirect", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".components.type.redirect"), + "redirect"), + CONTENT_LOADED( + "Content Loaded", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".components.type.contentLoaded"), + "contentLoaded"), + NODE_ADDED( + "Node Added", + Constant.messages.getString(ExtensionClientIntegration.PREFIX + ".type.nodeAdded"), + "nodeAdded"), + DOM_MUTATION( + "DOM Mutation", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".type.domMutation"), + "domMutation"), + PAGE_LOAD( + "Page Load", + Constant.messages.getString(ExtensionClientIntegration.PREFIX + ".type.pageLoad"), + "pageLoad"), + PAGE_UNLOAD( + "Page Unload", + Constant.messages.getString(ExtensionClientIntegration.PREFIX + ".type.pageUnload"), + "pageUnload"), + UNKNOWN( + "Unknown", + Constant.messages.getString("client.components.type.unknown"), + "Unknown"); + + private String label; + private String name; + private String typeKey; + + private Type(String label, String name, String typeKey) { + this.label = label; + this.name = name; + this.typeKey = typeKey; + } + + public String getLabel() { + return label; + } + + public String getName() { + return name; + } + + public String getTypeKey() { + return typeKey; + } + + public static Type getTypeForKey(String key) { + for (Type type : Type.values()) { + if (type.getTypeKey().equals(key)) { + return type; + } + } + return Type.UNKNOWN; + } + } + private final Map data; private String tagName; @@ -43,7 +142,7 @@ public class ClientSideComponent { private String parentUrl; private String href; private String text; - private String type; + @NonNull private Type type; private String tagType; private int formId = -1; @@ -56,7 +155,7 @@ public ClientSideComponent(JSONObject json) { this.tagName = json.getString("tagName"); this.id = json.getString("id"); this.parentUrl = json.getString("url"); - this.type = json.getString("type"); + this.type = Type.getTypeForKey(json.getString("type")); if (json.containsKey("href")) { this.href = json.getString("href"); } @@ -78,18 +177,21 @@ public Map getData() { public String getTypeForDisplay() { switch (tagName) { case "A": - return Constant.messages.getString( - ExtensionClientIntegration.PREFIX + ".components.type.link"); + return Type.LINK.getName(); case "BUTTON": - return Constant.messages.getString( - ExtensionClientIntegration.PREFIX + ".components.type.button"); + return Type.BUTTON.getName(); case "INPUT": - return Constant.messages.getString( - ExtensionClientIntegration.PREFIX + ".components.type.input"); + return Type.INPUT.getName(); default: - String key = ExtensionClientIntegration.PREFIX + ".type." + type; - if (tagName.isEmpty() && Constant.messages.containsKey(key)) { - return Constant.messages.getString(key); + if (type != null) { + String key = ExtensionClientIntegration.PREFIX + ".type." + type.getName(); + if (tagName.isEmpty() && Constant.messages.containsKey(key)) { + return Constant.messages.getString(key); + } + key = ExtensionClientIntegration.PREFIX + ".type." + type.getTypeKey(); + if (tagName.isEmpty() && Constant.messages.containsKey(key)) { + return Constant.messages.getString(key); + } } return tagName; } @@ -100,7 +202,7 @@ public boolean isStorageEvent() { return false; } switch (type) { - case "Cookies", "localStorage", "sessionStorage": + case COOKIES, LOCAL_STORAGE, SESSION_STORAGE: return true; default: return false; @@ -124,4 +226,57 @@ public boolean equals(Object obj) { && Objects.equals(tagName, other.tagName) && Objects.equals(text, other.text); } + + @Override + public int compareTo(ClientSideComponent other) { + if (this.type == null || other.type == null) { + return nullCompare(this.type, other.type); + } + int result = stringCompare(this.getType().getLabel(), other.getType().getLabel()); + if (result != 0) { + return result; + } + 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; + } + result = Integer.compare(this.formId, other.formId); + if (result != 0) { + return result; + } + return 0; + } + + private static int stringCompare(String here, String other) { + if (here == null || other == null) { + return nullCompare(here, other); + } + return here.compareTo(other); + } + + private static int nullCompare(Object here, Object other) { + if (here == other) { + 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 0000000000..d0b3d11fb6 --- /dev/null +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/ui/PopupMenuExportClientMap.java @@ -0,0 +1,114 @@ +/* + * 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.File; +import java.util.Locale; +import javax.swing.JFileChooser; +import javax.swing.filechooser.FileNameExtensionFilter; +import org.parosproxy.paros.Constant; +import org.parosproxy.paros.extension.ExtensionPopupMenuItem; +import org.zaproxy.addon.client.ExtensionClientIntegration; +import org.zaproxy.addon.commonlib.MenuWeights; +import org.zaproxy.zap.view.widgets.WritableFileChooser; + +@SuppressWarnings("serial") +public class PopupMenuExportClientMap extends ExtensionPopupMenuItem { + + private static final long serialVersionUID = 1L; + private static final String YAML_EXT = ".yaml"; + + private ExtensionClientIntegration extension; + + /** + * 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."); + } + this.extension = extension; + + this.addActionListener(e -> performAction()); + } + + @Override + public boolean isEnableForComponent(Component invoker) { + return "treeClient".equals(invoker.getName()); + } + + private void performAction() { + File file = getOutputFile(); + if (file == null) { + return; + } + + extension.exportClientMap(file.getAbsolutePath()); + } + + private 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/javahelp/org/zaproxy/addon/client/resources/help/contents/client.html b/addOns/client/src/main/javahelp/org/zaproxy/addon/client/resources/help/contents/client.html index ed3b0a0a2f..9e798d89f2 100644 --- a/addOns/client/src/main/javahelp/org/zaproxy/addon/client/resources/help/contents/client.html +++ b/addOns/client/src/main/javahelp/org/zaproxy/addon/client/resources/help/contents/client.html @@ -77,6 +77,10 @@

Client Map

The following context menu items are supported: +

Export Client Map

+ +Allows users to export a representation of the Client Map in YAML format. Including all children and the associated component details of each node. +

Copy URLs

Copies the URLs of the selected nodes into the clipboard, separated by newlines. 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 992846a27f..2b0ec671e1 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 @@ -31,9 +31,13 @@ client.components.table.header.tagType = Tag Type client.components.table.header.text = Text client.components.table.header.type = Type client.components.type.button = Button +client.components.type.contentLoaded = Content Loaded +client.components.type.form = Form client.components.type.input = Input client.components.type.link = Link +client.components.type.redirect = Redirect +client.components.type.unknown = Unknown client.desc = Client Side Integration client.details.popup.copy.hrefs = Copy HREFs @@ -165,11 +169,14 @@ client.spider.toolbar.progress.label = Progress: client.spider.toolbar.progress.select = --Select Scan-- client.spider.toolbar.urls.label = Crawled URLs: +client.tree.export.error = Error saving file to {0} client.tree.popup.attack = Attack 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.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 0000000000..2bac0f3e14 --- /dev/null +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapWriterUnitTest.java @@ -0,0 +1,395 @@ +/* + * 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.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.ExtensionClientIntegration; +import org.zaproxy.zap.ZAP; +import org.zaproxy.zap.testutils.TestUtils; + +class ClientMapWriterUnitTest extends TestUtils { + + private ClientNode root; + private ClientMap map; + + @BeforeAll + static void init() { + mockMessages(new ExtensionClientIntegration()); + } + + @BeforeEach + void setup() { + Session session = mock(Session.class); + 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.example.com"; + ClientNode zoo = map.getOrAddNode(zooUrl, true, false); + // localStorage + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "some-sid", + "", + zooUrl, + "foo", + ClientSideComponent.Type.LOCAL_STORAGE, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "a-sid", + "", + zooUrl, + "foo", + ClientSideComponent.Type.LOCAL_STORAGE, + "", + -1)); + // sessionStorage + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "z-bar", + "", + zooUrl, + "fooz", + ClientSideComponent.Type.SESSION_STORAGE, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "z-bar", + "", + zooUrl, + "fooa", + ClientSideComponent.Type.SESSION_STORAGE, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "a-bar", + "", + zooUrl, + "fooa", + ClientSideComponent.Type.SESSION_STORAGE, + "", + -1)); + // Cookies + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + zooUrl, + "aNotDisplayed", + ClientSideComponent.Type.COOKIES, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + zooUrl, + "zNotDisplayed", + ClientSideComponent.Type.COOKIES, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "", + "fooz", + "", + zooUrl, + "zNotDisplayed", + ClientSideComponent.Type.COOKIES, + "", + -1)); + // Links + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "A", + "foo-logo", + "", + "https://foo.example.com/", + "", + ClientSideComponent.Type.LINK, + "", + -1)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "A", + "", + "", + "https://foo.example.com/", + "Foo Example", + ClientSideComponent.Type.LINK, + "", + -1)); + // Buttons + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "BUTTON", + "", + "", + "", + "", + ClientSideComponent.Type.BUTTON, + "", + -1)); + // Inputs + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "INPUT", + "", + "", + "", + "", + ClientSideComponent.Type.INPUT, + "", + 0)); + // Forms + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "FORM", + "head-search", + "", + "", + "", + ClientSideComponent.Type.FORM, + "", + 0)); + zoo.getUserObject() + .addComponent( + new ClientSideComponent( + Map.of(), + "FORM", + "foot-search", + "", + "", + "", + ClientSideComponent.Type.FORM, + "", + 1)); + map.getOrAddNode("https://foo.example.com", true, false); + map.getOrAddNode("https://1acme.example.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.example.com" + - node: "https://example.com" + visited: false + children: + - node: "/" + - node: "aaa" + - node: "zaa" + - node: "zzz" + - node: "https://foo.example.com" + - node: "https://zoo.example.com" + components: + - nodeType: "Button" + href: "" + text: "" + id: "" + tagName: "BUTTON" + tagType: "" + - nodeType: "Cookies" + href: "https://zoo.example.com" + id: "foo" + tagName: "" + tagType: "" + storageEvent: true + - nodeType: "Cookies" + href: "https://zoo.example.com" + id: "foo" + tagName: "" + tagType: "" + storageEvent: true + - nodeType: "Cookies" + href: "https://zoo.example.com" + id: "fooz" + tagName: "" + tagType: "" + storageEvent: true + - nodeType: "Form" + href: "" + text: "" + id: "foot-search" + tagName: "FORM" + tagType: "" + formId: 1 + - nodeType: "Form" + href: "" + text: "" + id: "head-search" + tagName: "FORM" + tagType: "" + formId: 0 + - nodeType: "Input" + href: "" + text: "" + id: "" + tagName: "INPUT" + tagType: "" + formId: 0 + - nodeType: "Link" + href: "https://foo.example.com/" + text: "" + id: "foo-logo" + tagName: "A" + tagType: "" + - nodeType: "Link" + href: "https://foo.example.com/" + text: "Foo Example" + id: "" + tagName: "A" + tagType: "" + - nodeType: "Local Storage" + href: "https://zoo.example.com" + id: "a-sid" + tagName: "" + tagType: "" + storageEvent: true + - nodeType: "Local Storage" + href: "https://zoo.example.com" + id: "some-sid" + tagName: "" + tagType: "" + storageEvent: true + - nodeType: "Session Storage" + href: "https://zoo.example.com" + id: "a-bar" + tagName: "" + tagType: "" + storageEvent: true + - nodeType: "Session Storage" + href: "https://zoo.example.com" + id: "z-bar" + tagName: "" + tagType: "" + storageEvent: true + - nodeType: "Session Storage" + href: "https://zoo.example.com" + id: "z-bar" + tagName: "" + tagType: "" + storageEvent: true + """))); + } +} diff --git a/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponentUnitTest.java b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponentUnitTest.java new file mode 100644 index 0000000000..30171d990b --- /dev/null +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideComponentUnitTest.java @@ -0,0 +1,463 @@ +/* + * 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 static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +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.addon.client.internal.ClientSideComponent.Type; +import org.zaproxy.zap.testutils.TestUtils; + +/** Unit Tests for {@code ClientSideComponent} */ +class ClientSideComponentUnitTest extends TestUtils { + + private static final String EXAMPLE_URL = "https://example.com"; + private static final String ZOO_URL = "https://zoo.example.com"; + + @BeforeAll + static void init() { + mockMessages(new ExtensionClientIntegration()); + } + + @Test + void shouldThrowIfTypeIsNullWhenConstructing() { + // Given / When / Then + assertThrows( + NullPointerException.class, + () -> + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + EXAMPLE_URL, + "zNotDisplayed", + null, + "", + -1)); + } + + @Test + void shouldOrderByTypeForDisplayThenHrefThenTextSameHrefs() { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + EXAMPLE_URL, + "zNotDisplayed", + ClientSideComponent.Type.COOKIES, + "", + -1); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + "A", + "foo", + "", + EXAMPLE_URL, + "zLink", + ClientSideComponent.Type.LINK, + "", + -1); + ClientSideComponent three = + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + EXAMPLE_URL, + "aNotDisplayed", + ClientSideComponent.Type.COOKIES, + "", + -1); + ClientSideComponent four = + new ClientSideComponent( + Map.of(), + "A", + "foo", + "", + EXAMPLE_URL, + "aLink", + ClientSideComponent.Type.LINK, + "", + -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", + ClientSideComponent.Type.COOKIES, + "", + -1); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + "A", + "foo", + "", + EXAMPLE_URL, + "zLink", + ClientSideComponent.Type.LINK, + "", + -1); + ClientSideComponent three = + new ClientSideComponent( + Map.of(), + "", + "foo", + "", + ZOO_URL, + "aNotDisplayed", + ClientSideComponent.Type.COOKIES, + "", + -1); + ClientSideComponent four = + new ClientSideComponent( + Map.of(), + "A", + "foo", + "", + EXAMPLE_URL, + "aLink", + ClientSideComponent.Type.LINK, + "", + -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( + // Length + Arguments.of(List.of("/aaaa", "/a", "/aa", "/aaa")), + // Alpha .. 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", + ClientSideComponent.Type.LINK, + "", + -1); + } + + private static Stream getTypePairsAndExpectedResult() { + return Stream.of( + Arguments.of(Type.BUTTON, Type.BUTTON, 0), + Arguments.of(Type.BUTTON, Type.SESSION_STORAGE, -17), + Arguments.of(Type.COOKIES, Type.BUTTON, 1)); + } + + @ParameterizedTest + @MethodSource("getTypePairsAndExpectedResult") + void shouldCompareTypesByLabel(Type first, Type second, int expected) { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + EXAMPLE_URL, + "text", + first, + "tagType", + -1); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + EXAMPLE_URL, + "text", + second, + "tagType", + -1); + // When + int actual = one.compareTo(two); + // Then + assertThat(actual, is(equalTo(expected))); + } + + private static Stream getStringPairsAndExpectedResult() { + return Stream.of( + Arguments.of("example", "example", 0), + Arguments.of("example", "zoo", -21), + Arguments.of("zoo", "example", 21), + Arguments.of(null, null, 0), + Arguments.of(null, "example", -1), + Arguments.of("example", null, 1)); + } + + @ParameterizedTest + @MethodSource("getStringPairsAndExpectedResult") + void shouldCompareHrefsAsExpected(String first, String second, int expected) { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + first, + "text", + Type.BUTTON, + "tagType", + -1); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + second, + "text", + Type.BUTTON, + "tagType", + -1); + // When + int actual = one.compareTo(two); + // Then + assertThat(actual, is(equalTo(expected))); + } + + @ParameterizedTest + @MethodSource("getStringPairsAndExpectedResult") + void shouldCompareTextAsExpected(String first, String second, int expected) { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + EXAMPLE_URL, + first, + Type.BUTTON, + "tagType", + -1); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + EXAMPLE_URL, + second, + Type.BUTTON, + "tagType", + -1); + // When + int actual = one.compareTo(two); + // Then + assertThat(actual, is(equalTo(expected))); + } + + @ParameterizedTest + @MethodSource("getStringPairsAndExpectedResult") + void shouldCompareIdAsExpected(String first, String second, int expected) { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), + "tagName", + first, + EXAMPLE_URL, + EXAMPLE_URL, + "text", + Type.BUTTON, + "tagType", + -1); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + "tagName", + second, + EXAMPLE_URL, + EXAMPLE_URL, + "text", + Type.BUTTON, + "tagType", + -1); + // When + int actual = one.compareTo(two); + // Then + assertThat(actual, is(equalTo(expected))); + } + + @ParameterizedTest + @MethodSource("getStringPairsAndExpectedResult") + void shouldCompareTagNameAsExpected(String first, String second, int expected) { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), + first, + "id", + EXAMPLE_URL, + EXAMPLE_URL, + "text", + Type.BUTTON, + "tagType", + -1); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + second, + "id", + EXAMPLE_URL, + EXAMPLE_URL, + "text", + Type.BUTTON, + "tagType", + -1); + // When + int actual = one.compareTo(two); + // Then + assertThat(actual, is(equalTo(expected))); + } + + @ParameterizedTest + @MethodSource("getStringPairsAndExpectedResult") + void shouldCompareTagTypeAsExpected(String first, String second, int expected) { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + EXAMPLE_URL, + "text", + Type.BUTTON, + first, + -1); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + EXAMPLE_URL, + "text", + Type.BUTTON, + second, + -1); + // When + int actual = one.compareTo(two); + // Then + assertThat(actual, is(equalTo(expected))); + } + + private static Stream getFormIdPairsAndExpectedResult() { + return Stream.of(Arguments.of(7, 7, 0), Arguments.of(10, 7, 1), Arguments.of(7, 10, -1)); + } + + @ParameterizedTest + @MethodSource("getFormIdPairsAndExpectedResult") + void shouldCompareFormIdAsExpected(int first, int second, int expected) { + // Given + ClientSideComponent one = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + EXAMPLE_URL, + "text", + Type.BUTTON, + "tagType", + first); + ClientSideComponent two = + new ClientSideComponent( + Map.of(), + "tagName", + "id", + EXAMPLE_URL, + EXAMPLE_URL, + "text", + Type.BUTTON, + "tagType", + second); + // When + int actual = one.compareTo(two); + // Then + assertThat(actual, is(equalTo(expected))); + } +} diff --git a/addOns/client/src/test/java/org/zaproxy/addon/client/spider/ClientSpiderUnitTest.java b/addOns/client/src/test/java/org/zaproxy/addon/client/spider/ClientSpiderUnitTest.java index 25d1291180..4853d71faf 100644 --- a/addOns/client/src/test/java/org/zaproxy/addon/client/spider/ClientSpiderUnitTest.java +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/spider/ClientSpiderUnitTest.java @@ -389,7 +389,7 @@ void shouldHandleComponentHrefWithoutHostname() { url, "#", null, - null, + ClientSideComponent.Type.LINK, null, -1)); try {