From 0dc312410bd044e10c7db33426c0ad1e200865d8 Mon Sep 17 00:00:00 2001 From: thc202 Date: Thu, 11 Jun 2026 10:05:19 +0100 Subject: [PATCH] client: consume interactable state Consume the interactable state and the `nodeChanged` event sent from the browser extension. Signed-off-by: thc202 --- .../addon/client/ComponentTableModel.java | 17 ++- .../addon/client/internal/ClientMap.java | 22 +++ .../client/internal/ClientSideComponent.java | 30 +++- .../client/internal/ClientSideDetails.java | 13 ++ .../client/internal/InteractableState.java | 29 ++++ .../client/resources/Messages.properties | 2 + .../client/internal/ClientMapUnitTest.java | 82 ++++++++++ .../internal/ClientSideComponentUnitTest.java | 46 ++++++ .../internal/ClientSideDetailsUnitTest.java | 141 ++++++++++++++++++ 9 files changed, 377 insertions(+), 5 deletions(-) create mode 100644 addOns/client/src/main/java/org/zaproxy/addon/client/internal/InteractableState.java create mode 100644 addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideDetailsUnitTest.java diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/ComponentTableModel.java b/addOns/client/src/main/java/org/zaproxy/addon/client/ComponentTableModel.java index 607a5d88951..a5281a220f6 100644 --- a/addOns/client/src/main/java/org/zaproxy/addon/client/ComponentTableModel.java +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/ComponentTableModel.java @@ -24,6 +24,7 @@ import javax.swing.table.AbstractTableModel; import org.parosproxy.paros.Constant; import org.zaproxy.addon.client.internal.ClientSideComponent; +import org.zaproxy.addon.client.internal.InteractableState; public class ComponentTableModel extends AbstractTableModel { @@ -32,6 +33,8 @@ public class ComponentTableModel extends AbstractTableModel { private static final String[] COLUMN_NAMES = { Constant.messages.getString( ExtensionClientIntegration.PREFIX + ".components.table.header.type"), + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".components.table.header.interactable"), Constant.messages.getString( ExtensionClientIntegration.PREFIX + ".components.table.header.id"), Constant.messages.getString( @@ -74,6 +77,9 @@ public int getColumnCount() { @Override public Class getColumnClass(int c) { + if (c == 1) { + return Boolean.class; + } return String.class; } @@ -98,21 +104,24 @@ public Object getValueAt(int rowIndex, int columnIndex) { case 0: return component.getTypeForDisplay(); case 1: - return component.getId(); + InteractableState s = component.getInteractable(); + return s == null || (s.isVisible() && s.isEnabled()); case 2: - return component.getTagType(); + return component.getId(); case 3: + return component.getTagType(); + case 4: int formId = component.getFormId(); if (formId >= 0) { return Integer.toString(formId); } return ""; - case 4: + case 5: if (component.isStorageEvent()) { return component.getParentUrl(); } return component.getHref(); - case 5: + case 6: return component.getText(); default: return null; 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 7c9cb26e80a..1d7d998f605 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 @@ -325,6 +325,11 @@ public void handleReportObject(String jsonStr, int source) { if (url != null) { if (!isApiUrl(url)) { ClientSideComponent component = new ClientSideComponent(json); + if (ClientSideComponent.Type.NODE_CHANGED == component.getType()) { + handleNodeChanged(component, source); + return; + } + addComponent(url, component, source); if (http && isLinkComponent(component)) { addGraphEdge(url, href, component); @@ -338,6 +343,23 @@ public void handleReportObject(String jsonStr, int source) { } } + private void handleNodeChanged(ClientSideComponent component, int source) { + ClientNode node = getNode(component.getParentUrl(), false, false); + if (node == null) { + return; + } + + boolean changed = + node.getUserObject() + .updateComponentInteractable( + component.getId(), + component.getTagName(), + component.getInteractable()); + if (changed) { + notifyNodeChanged(node); + } + } + private static boolean isLinkComponent(ClientSideComponent component) { return component.getType() == ClientSideComponent.Type.LINK || "A".equals(component.getTagName()); 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 a994256cd74..b3ec94ab7b0 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 @@ -25,6 +25,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NonNull; +import lombok.Setter; import net.sf.json.JSONObject; import org.parosproxy.paros.Constant; import org.zaproxy.addon.client.ExtensionClientIntegration; @@ -85,6 +86,11 @@ public enum Type { "Node Added", Constant.messages.getString(ExtensionClientIntegration.PREFIX + ".type.nodeAdded"), "nodeAdded"), + NODE_CHANGED( + "Node Changed", + Constant.messages.getString( + ExtensionClientIntegration.PREFIX + ".type.nodeChanged"), + "nodeChanged"), DOM_MUTATION( "DOM Mutation", Constant.messages.getString( @@ -144,7 +150,21 @@ public static Type getTypeForKey(String key) { private String text; @NonNull private Type type; private String tagType; - private int formId = -1; + private int formId; + @Setter private InteractableState interactable; + + public ClientSideComponent( + Map data, + String tagName, + String id, + String parentUrl, + String href, + String text, + Type type, + String tagType, + int formId) { + this(data, tagName, id, parentUrl, href, text, type, tagType, formId, null); + } public ClientSideComponent(JSONObject json) { data = new HashMap<>(); @@ -168,6 +188,14 @@ public ClientSideComponent(JSONObject json) { if (json.containsKey("formId")) { this.formId = json.getInt("formId"); } + if (json.containsKey("interactable") && !json.get("interactable").equals("null")) { + JSONObject s = json.getJSONObject("interactable"); + this.interactable = + new InteractableState( + s.optBoolean("visible", false), + s.optBoolean("enabled", false), + s.optBoolean("pointer", false)); + } } public Map getData() { diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideDetails.java b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideDetails.java index ceb5565461d..d711130e14c 100644 --- a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideDetails.java +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/ClientSideDetails.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.HashSet; +import java.util.Objects; import java.util.Set; import lombok.Getter; @@ -62,6 +63,18 @@ protected boolean addComponent(ClientSideComponent component) { return this.components.add(component); } + public boolean updateComponentInteractable( + String id, String tagName, InteractableState interactable) { + for (ClientSideComponent c : components) { + if (Objects.equals(c.getId(), id) && Objects.equals(c.getTagName(), tagName)) { + boolean changed = !Objects.equals(c.getInteractable(), interactable); + c.setInteractable(interactable); + return changed; + } + } + return false; + } + protected void setStorage(boolean storage) { this.storage = storage; } diff --git a/addOns/client/src/main/java/org/zaproxy/addon/client/internal/InteractableState.java b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/InteractableState.java new file mode 100644 index 00000000000..b2091ff9cb5 --- /dev/null +++ b/addOns/client/src/main/java/org/zaproxy/addon/client/internal/InteractableState.java @@ -0,0 +1,29 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 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 lombok.Value; + +@Value +public class InteractableState { + boolean visible; + boolean enabled; + boolean pointer; +} 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 f8f58d9100e..82a9bace11e 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 @@ -91,6 +91,7 @@ client.clientSpider.api.view.status.param.scanId = The ID of the client spider s client.components.table.header.form = Form ID client.components.table.header.href = HREF client.components.table.header.id = ID +client.components.table.header.interactable = Interactable client.components.table.header.tagType = Tag Type client.components.table.header.text = Text client.components.table.header.type = Type @@ -262,6 +263,7 @@ client.type.Cookies = Cookies client.type.domMutation = DOM Mutation client.type.localStorage = Local Storage client.type.nodeAdded = Node Added +client.type.nodeChanged = Node Changed client.type.pageLoad = Page Load client.type.pageUnload = Page Unload client.type.sessionStorage = Session Storage diff --git a/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapUnitTest.java b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapUnitTest.java index 6df6e3c789e..a1ab4bed81e 100644 --- a/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapUnitTest.java +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientMapUnitTest.java @@ -27,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; @@ -66,6 +67,18 @@ class ClientMapUnitTest extends TestUtils { "timestamp": 0 }"""; + private static final String NODE_CHANGED_JSON = + """ + { + "tagName": "%s", + "id": "%s", + "type": "nodeChanged", + "url": "%s", + "nodeName": "%s", + "timestamp": 0, + "interactable": {"visible": %s, "enabled": %s, "pointer": %s} + }"""; + private static final String REPORTED_EVENT_JSON = """ { @@ -1082,4 +1095,73 @@ void shouldTraverseGraphFromUrlThroughComponentToUrl() { var targetVertex = graph.getEdgeTarget(componentOutEdges.iterator().next()); assertThat(targetVertex, is(new ClientGraphVertex.Url(href))); } + + @Test + void shouldDoNothingOnNodeChangedForUnknownNode() { + // Given + String json = + NODE_CHANGED_JSON.formatted( + "INPUT", "", "https://www.example.com/page", "INPUT", true, true, false); + + // When + map.handleReportObject(json); + + // Then + assertThat(map.getRoot().getChildCount(), is(0)); + verifyNoInteractions(listener); + } + + @Test + void shouldNotUpdateInteractableWhenNoMatchingComponentInNode() { + // Given + String url = "https://www.example.com/page"; + map.handleReportObject(REPORTED_OBJECT_JSON.formatted(url, null)); + ClientSideComponent existing = + map.getNode(url, false, false).getUserObject().getComponents().iterator().next(); + String json = + NODE_CHANGED_JSON.formatted("BUTTON", "btn1", url, "BUTTON", true, true, true); + + // When + map.handleReportObject(json); + + // Then + assertThat(existing.getInteractable(), is(nullValue())); + verify(listener).componentAdded(any(), eq(0)); + } + + @Test + void shouldUpdateComponentInteractableOnNodeChanged() { + // Given + String url = "https://www.example.com/page"; + map.handleReportObject(REPORTED_OBJECT_JSON.formatted(url, null)); + ClientSideComponent existing = + map.getNode(url, false, false).getUserObject().getComponents().iterator().next(); + String json = NODE_CHANGED_JSON.formatted("INPUT", "", url, "INPUT", true, true, false); + + // When + map.handleReportObject(json); + + // Then + assertThat(existing.getInteractable(), is(new InteractableState(true, true, false))); + verify(listener).componentAdded(any(), eq(0)); + } + + @Test + void shouldNotUpdateWhenInteractableAlreadySameState() { + // Given + InteractableState state = new InteractableState(true, false, true); + String url = "https://www.example.com/page"; + map.handleReportObject(REPORTED_OBJECT_JSON.formatted(url, null)); + ClientSideComponent existing = + map.getNode(url, false, false).getUserObject().getComponents().iterator().next(); + existing.setInteractable(state); + String json = NODE_CHANGED_JSON.formatted("INPUT", "", url, "INPUT", true, false, true); + + // When + map.handleReportObject(json); + + // Then + assertThat(existing.getInteractable(), is(state)); + verify(listener).componentAdded(any(), eq(0)); + } } 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 index 30171d990b3..ce15ee0de18 100644 --- 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 @@ -23,6 +23,8 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.List; @@ -31,6 +33,7 @@ import java.util.SortedSet; import java.util.TreeSet; import java.util.stream.Stream; +import net.sf.json.JSONObject; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -460,4 +463,47 @@ void shouldCompareFormIdAsExpected(int first, int second, int expected) { // Then assertThat(actual, is(equalTo(expected))); } + + @Test + void shouldDefaultInteractableToNullWhenConstructedWithMapArgs() { + // Given / When + ClientSideComponent component = + new ClientSideComponent( + Map.of(), "BUTTON", "btn1", EXAMPLE_URL, null, "", Type.BUTTON, "", -1); + // Then + assertThat(component.getInteractable(), is(nullValue())); + } + + @Test + void shouldDefaultInteractableToNullWhenParsedFromJsonWithoutField() { + // Given + JSONObject json = + JSONObject.fromObject( + """ + {"tagName": "BUTTON", "id": "btn1", "type": "button", "url": "%s", "timestamp": 0}""" + .formatted(EXAMPLE_URL)); + // When + ClientSideComponent component = new ClientSideComponent(json); + // Then + assertThat(component.getInteractable(), is(nullValue())); + } + + @Test + void shouldParseInteractableObjectFromJson() { + // Given + JSONObject json = + JSONObject.fromObject( + """ + {"tagName": "BUTTON", "id": "btn1", "type": "button", "url": "%s", "timestamp": 0, + "interactable": {"visible": true, "enabled": false, "pointer": true}}""" + .formatted(EXAMPLE_URL)); + // When + ClientSideComponent component = new ClientSideComponent(json); + // Then + InteractableState state = component.getInteractable(); + assertThat(state, is(notNullValue())); + assertThat(state.isVisible(), is(true)); + assertThat(state.isEnabled(), is(false)); + assertThat(state.isPointer(), is(true)); + } } diff --git a/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideDetailsUnitTest.java b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideDetailsUnitTest.java new file mode 100644 index 00000000000..82b71af5751 --- /dev/null +++ b/addOns/client/src/test/java/org/zaproxy/addon/client/internal/ClientSideDetailsUnitTest.java @@ -0,0 +1,141 @@ +/* + * Zed Attack Proxy (ZAP) and its related class files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 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.is; +import static org.hamcrest.Matchers.nullValue; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.zaproxy.addon.client.internal.ClientSideComponent.Type; + +/** Unit tests for {@code ClientSideDetails}. */ +class ClientSideDetailsUnitTest { + + private static final String EXAMPLE_URL = "https://example.com"; + + private static final InteractableState INTERACTABLE = new InteractableState(true, true, true); + private static final InteractableState NOT_INTERACTABLE = + new InteractableState(false, false, false); + + @Test + void shouldReturnFalseForUpdateComponentInteractableWhenNoComponentMatchesIdAndTagName() { + // Given + ClientSideDetails details = new ClientSideDetails("Page", EXAMPLE_URL); + details.addComponent(component("BUTTON", "btn1")); + + // When + boolean changed = details.updateComponentInteractable("btn2", "BUTTON", INTERACTABLE); + + // Then + assertThat(changed, is(false)); + } + + @Test + void shouldReturnFalseForUpdateComponentInteractableWhenInteractableAlreadySameValue() { + // Given + ClientSideDetails details = new ClientSideDetails("Page", EXAMPLE_URL); + details.addComponent(component("BUTTON", "btn1")); + + // When + boolean changed = details.updateComponentInteractable("btn1", "BUTTON", null); + + // Then + assertThat(changed, is(false)); + } + + @Test + void shouldReturnTrueAndUpdateForUpdateComponentInteractableWhenInteractableChanges() { + // Given + ClientSideDetails details = new ClientSideDetails("Page", EXAMPLE_URL); + ClientSideComponent component = component("BUTTON", "btn1"); + details.addComponent(component); + + // When + boolean changed = details.updateComponentInteractable("btn1", "BUTTON", INTERACTABLE); + + // Then + assertThat(changed, is(true)); + assertThat(component.getInteractable(), is(INTERACTABLE)); + } + + @Test + void shouldNotMatchByIdAloneForUpdateComponentInteractableWhenTagNameDiffers() { + // Given + ClientSideDetails details = new ClientSideDetails("Page", EXAMPLE_URL); + details.addComponent(component("BUTTON", "btn1")); + + // When + boolean changed = details.updateComponentInteractable("btn1", "INPUT", INTERACTABLE); + + // Then + assertThat(changed, is(false)); + } + + @Test + void shouldNotMatchByTagNameAloneForUpdateComponentInteractableWhenIdDiffers() { + // Given + ClientSideDetails details = new ClientSideDetails("Page", EXAMPLE_URL); + details.addComponent(component("BUTTON", "btn1")); + + // When + boolean changed = details.updateComponentInteractable("btn2", "BUTTON", INTERACTABLE); + + // Then + assertThat(changed, is(false)); + } + + @Test + void shouldMatchComponentWithEmptyIdByTagNameForUpdateComponentInteractable() { + // Given + ClientSideDetails details = new ClientSideDetails("Page", EXAMPLE_URL); + ClientSideComponent component = component("BUTTON", ""); + details.addComponent(component); + + // When + boolean changed = details.updateComponentInteractable("", "BUTTON", NOT_INTERACTABLE); + + // Then + assertThat(changed, is(true)); + assertThat(component.getInteractable(), is(NOT_INTERACTABLE)); + } + + @Test + void shouldSetInteractableToNullForUpdateComponentInteractable() { + // Given + ClientSideDetails details = new ClientSideDetails("Page", EXAMPLE_URL); + ClientSideComponent component = component("BUTTON", "btn1"); + component.setInteractable(INTERACTABLE); + details.addComponent(component); + + // When + boolean changed = details.updateComponentInteractable("btn1", "BUTTON", null); + + // Then + assertThat(changed, is(true)); + assertThat(component.getInteractable(), is(nullValue())); + } + + private static ClientSideComponent component(String tagName, String id) { + return new ClientSideComponent( + Map.of(), tagName, id, EXAMPLE_URL, null, "", Type.BUTTON, "", -1); + } +}