From 1b4cc9351590d503bbfea15e85aa168aa5bae235 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 --- .../client/ExtensionClientIntegration.java | 34 ++++ .../client/internal/ClientMapWriter.java | 164 ++++++++++++++++++ .../client/internal/ClientSideComponent.java | 64 +++++-- .../client/ui/PopupMenuExportClientMap.java | 145 ++++++++++++++++ .../client/resources/Messages.properties | 3 + .../internal/ClientMapWriterUnitTest.java | 104 +++++++++++ 6 files changed, 502 insertions(+), 12 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 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..c72784aeba0 --- /dev/null +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientMapWriter.java @@ -0,0 +1,164 @@ +/* + * 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.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 NAME_KEY = "name"; + private static final String STORAGE_KEY = "isStorage"; + private static final String VISITED_KEY = "visited"; + + 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.toString()); + + outputKV(fw, indent, false, NAME_KEY, node.getUserObject().getName()); + outputKV(fw, indent, false, STORAGE_KEY, node.getUserObject().isStorage()); + outputKV(fw, indent, false, VISITED_KEY, node.getUserObject().isVisited()); + if (node.getUserObject().getComponents() != null + && !node.getUserObject().getComponents().isEmpty()) { + 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) { + first = outputKV(fw, indent, first, "typeForDisplay", 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()); + 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..971179d94e0 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,6 +20,7 @@ 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; @@ -30,12 +31,13 @@ @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", "localStorage", "sessionStorage"); private final Map data; private String tagName; @@ -53,21 +55,29 @@ public ClientSideComponent(JSONObject json) { data.put(key.toString(), json.get(key).toString()); } - this.tagName = json.getString("tagName"); - this.id = json.getString("id"); - this.parentUrl = json.getString("url"); - this.type = json.getString("type"); + this.tagName = json.optString("tagName", ""); + this.id = json.optString("id", ""); + this.parentUrl = json.optString("url", ""); + this.type = json.optString("type", ""); if (json.containsKey("href")) { this.href = json.getString("href"); + } else { + this.href = ""; } if (json.containsKey("text")) { this.text = json.getString("text").trim(); + } else { + this.text = ""; } if (json.containsKey("tagType")) { this.tagType = json.getString("tagType").trim(); + } else { + this.tagType = ""; } if (json.containsKey("formId")) { this.formId = json.getInt("formId"); + } else { + this.formId = Integer.MIN_VALUE; } } @@ -99,12 +109,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 +129,39 @@ public boolean equals(Object obj) { && Objects.equals(tagName, other.tagName) && Objects.equals(text, other.text); } + + @Override + public int compareTo(ClientSideComponent other) { + int result = this.getTypeForDisplay().compareTo(other.getTypeForDisplay()); + if (result != 0) { + return result; + } + // Types are the same, check next relevant property(ies) + result = this.href.compareTo(other.href); + if (result != 0) { + return result; + } + result = this.text.compareTo(other.text); + if (result != 0) { + return result; + } + result = this.id.compareTo(other.id); + if (result != 0) { + return result; + } + result = this.tagName.compareTo(other.tagName); + if (result != 0) { + return result; + } + result = this.tagType.compareTo(other.tagType); + if (result != 0) { + return result; + } + if (this.formId > other.formId) { + return 1; + } else if (this.formId < other.formId) { + return -1; + } + return 0; + } } 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..9ac76614235 --- /dev/null +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/ui/PopupMenuExportClientMap.java @@ -0,0 +1,145 @@ +/* + * 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.JTree; +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())) { + JTree clientTree = (JTree) invoker; + setEnabled(clientTree.getRowCount() > 1); + 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..006390c3c9e --- /dev/null +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapWriterUnitTest.java @@ -0,0 +1,104 @@ +/* + * 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 org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.parosproxy.paros.model.Session; +import org.zaproxy.zap.ZAP; +import org.zaproxy.zap.model.StandardParameterParser; + +class ClientMapWriterUnitTest { + + private ClientNode root; + ClientMap map; + + @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" + name: "Root" + isStorage: false + visited: false + children: + - node: "https://example.com/" + name: "https://example.com" + isStorage: false + visited: false + children: + - node: "https://example.com/" + name: "/" + isStorage: false + visited: true + - node: "https://example.com/aaa" + name: "aaa" + isStorage: false + visited: true + - node: "https://example.com/zaa" + name: "zaa" + isStorage: false + visited: true + - node: "https://example.com/zzz" + name: "zzz" + isStorage: false + visited: true + """))); + } +}