Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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(
Expand Down Expand Up @@ -74,6 +77,9 @@ public int getColumnCount() {

@Override
public Class<?> getColumnClass(int c) {
if (c == 1) {
return Boolean.class;
}
return String.class;
}

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<String, String> 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<>();
Expand All @@ -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<String, String> getData() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import java.util.Collections;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import lombok.Getter;

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
"""
{
Expand Down Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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));
}
}
Loading
Loading