Skip to content

Commit 27c692c

Browse files
committed
af: Scripts support chaining
Signed-off-by: kingthorin <kingthorin@users.noreply.github.com>
1 parent b818cb1 commit 27c692c

14 files changed

Lines changed: 1721 additions & 56 deletions

File tree

addOns/scripts/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66
## Unreleased
77
### Changed
88
- Update dependency.
9-
- The Script Job Run action now supports passing authentication details (Context and User) for standalone Zest client script execution.
9+
10+
### Added
11+
- The Script Job Run action now supports:
12+
- Passing authentication details (context and user) for standalone Zest client script execution.
13+
- Executing a chain of one or more Zest standalone scripts using the chain parameter.
1014

1115
## [45.17.0] - 2025-12-15
1216
### Changed

addOns/scripts/src/main/java/org/zaproxy/zap/extension/scripts/automation/ScriptJobParameters.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
*/
2020
package org.zaproxy.zap.extension.scripts.automation;
2121

22+
import java.util.List;
2223
import lombok.AllArgsConstructor;
2324
import lombok.Getter;
2425
import lombok.NoArgsConstructor;
@@ -39,6 +40,7 @@ public class ScriptJobParameters extends AutomationData {
3940
private String inline = "";
4041
private String context = "";
4142
private String user = "";
43+
private List<String> chain;
4244

4345
public ScriptJobParameters(String action) {
4446
this.action = action;

addOns/scripts/src/main/java/org/zaproxy/zap/extension/scripts/automation/actions/RunScriptAction.java

Lines changed: 251 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@
2020
package org.zaproxy.zap.extension.scripts.automation.actions;
2121

2222
import java.lang.reflect.InvocationTargetException;
23+
import java.lang.reflect.Method;
2324
import java.security.InvalidParameterException;
2425
import java.util.ArrayList;
2526
import java.util.Arrays;
2627
import java.util.List;
2728
import java.util.Locale;
29+
import java.util.function.Consumer;
2830
import org.apache.commons.httpclient.URI;
2931
import org.apache.commons.lang3.StringUtils;
3032
import org.parosproxy.paros.Constant;
33+
import org.parosproxy.paros.control.Control;
3134
import org.parosproxy.paros.model.Model;
3235
import org.parosproxy.paros.model.SiteNode;
3336
import org.parosproxy.paros.network.HttpMessage;
@@ -45,6 +48,8 @@ public class RunScriptAction extends ScriptAction {
4548

4649
public static final String NAME = "run";
4750
private static final String ZEST_ENGINE_NAME = "Mozilla Zest";
51+
private static final String EXTENSION_ZEST_NAME = "ExtensionZest";
52+
private static final String RUN_NAME_CHAIN_PREFIX = "chain_";
4853
private static final List<String> SCRIPT_TYPES =
4954
Arrays.asList(ExtensionScript.TYPE_STANDALONE, ExtensionScript.TYPE_TARGETED);
5055
private static final List<String> DISABLED_FIELDS =
@@ -77,7 +82,18 @@ public List<String> verifyParameters(
7782
String issue;
7883
String scriptType = params.getType();
7984

80-
if (StringUtils.isEmpty(params.getName())) {
85+
boolean hasName = StringUtils.isNotEmpty(params.getName());
86+
boolean hasChain = params.getChain() != null && !params.getChain().isEmpty();
87+
88+
if (hasName && hasChain) {
89+
issue =
90+
Constant.messages.getString(
91+
"scripts.automation.warn.chainAndNameBothSpecified", jobName);
92+
list.add(issue);
93+
if (progress != null) {
94+
progress.warn(issue);
95+
}
96+
} else if (!hasName && !hasChain) {
8197
issue =
8298
Constant.messages.getString(
8399
"scripts.automation.error.scriptNameIsNull", jobName);
@@ -107,8 +123,16 @@ public List<String> verifyParameters(
107123
if (progress != null) {
108124
progress.error(issue);
109125
}
126+
} else if (hasChain && !ExtensionScript.TYPE_STANDALONE.equals(scriptType)) {
127+
issue =
128+
Constant.messages.getString(
129+
"scripts.automation.error.chainRequiresStandalone", jobName);
130+
list.add(issue);
131+
if (progress != null) {
132+
progress.error(issue);
133+
}
110134
}
111-
// Note dont warn/error if script not currently in ZAP - it might be added by another job
135+
// Script/chain existence not validated here; chain validated at runtime in runScriptChain()
112136
if (!StringUtils.isEmpty(params.getSource())) {
113137
issue =
114138
Constant.messages.getString(
@@ -192,63 +216,170 @@ public void runJob(String jobName, AutomationEnvironment env, AutomationProgress
192216
}
193217
}
194218

195-
ScriptJobOutputListener scriptJobOutputListener =
196-
new ScriptJobOutputListener(progress, parameters.getName());
219+
if (parameters.getChain() != null && !parameters.getChain().isEmpty()) {
220+
runScriptChain(jobName, user, progress);
221+
} else {
222+
runSingleScript(jobName, user, progress);
223+
}
224+
}
225+
226+
private void runScriptChain(String jobName, User user, AutomationProgress progress) {
227+
if (!ExtensionScript.TYPE_STANDALONE.equals(parameters.getType())) {
228+
progress.error(
229+
Constant.messages.getString(
230+
"scripts.automation.error.chainRequiresStandalone", jobName));
231+
return;
232+
}
233+
234+
List<ScriptWrapper> scriptWrappers =
235+
validateChainScripts(parameters.getChain(), jobName, progress);
236+
if (scriptWrappers == null) {
237+
return; // Validation failed, error already reported
238+
}
239+
240+
ScriptWrapper firstScript = scriptWrappers.get(0);
241+
String runName = RUN_NAME_CHAIN_PREFIX + firstScript.getName();
242+
ScriptWrapper chainScript;
197243
try {
198-
extScript.addScriptOutputListener(scriptJobOutputListener);
199-
ScriptWrapper script = findScript();
200-
if (script == null) {
201-
progress.error(
202-
Constant.messages.getString(
203-
"scripts.automation.error.scriptNameNotFound",
204-
jobName,
205-
parameters.getName()));
206-
return;
207-
}
244+
chainScript = getChainScriptViaReflection(scriptWrappers, runName);
245+
} catch (Exception e) {
246+
progress.error(
247+
Constant.messages.getString(
248+
"scripts.automation.error.chainPreparationFailed",
249+
jobName,
250+
e.getMessage()));
251+
return;
252+
}
253+
if (chainScript == null) {
254+
progress.error(
255+
Constant.messages.getString(
256+
"scripts.automation.error.chainReflectionFailed",
257+
jobName,
258+
firstScript.getName()));
259+
return;
260+
}
208261

209-
if (!getSupportedScriptTypes().contains(script.getTypeName())) {
210-
progress.error(
211-
Constant.messages.getString(
212-
"scripts.automation.error.scriptTypeNotSupported",
213-
jobName,
214-
script.getTypeName(),
215-
getName(),
216-
String.join(", ", getSupportedScriptTypes())));
217-
return;
218-
}
262+
setUserOnZestWrapper(chainScript, user);
219263

220-
if (parameters.getType().equals(ExtensionScript.TYPE_TARGETED)) {
221-
URI targetUri = new URI(parameters.getTarget(), true);
222-
SiteNode siteNode =
223-
Model.getSingleton().getSession().getSiteTree().findNode(targetUri);
224-
if (siteNode == null) {
225-
progress.error(
226-
Constant.messages.getString(
227-
"scripts.automation.error.scriptTargetNotFound",
228-
jobName,
229-
parameters.getTarget()));
230-
return;
231-
}
264+
progress.info(
265+
Constant.messages.getString(
266+
"scripts.automation.info.chainExecuting", jobName, scriptWrappers.size()));
232267

233-
HttpMessage httpMessage = siteNode.getHistoryReference().getHttpMessage();
234-
extScript.invokeTargetedScript(script, httpMessage);
235-
} else {
236-
setUserOnZestWrapper(script, user);
237-
extScript.invokeScript(script);
238-
}
268+
if (executeScriptWithOutputListener(
269+
chainScript,
270+
progress,
271+
() -> extScript.invokeScript(chainScript),
272+
(e) ->
273+
progress.error(
274+
Constant.messages.getString(
275+
"scripts.automation.error.chainExecutionFailed",
276+
jobName,
277+
e.getMessage())))) {
278+
progress.info(
279+
Constant.messages.getString("scripts.automation.info.chainCompleted", jobName));
280+
}
281+
}
282+
283+
private void runSingleScript(String jobName, User user, AutomationProgress progress) {
284+
ScriptWrapper script = findScript();
285+
if (script == null) {
286+
progress.error(
287+
Constant.messages.getString(
288+
"scripts.automation.error.scriptNameNotFound",
289+
jobName,
290+
parameters.getName()));
291+
return;
292+
}
293+
294+
if (!getSupportedScriptTypes().contains(script.getTypeName())) {
295+
progress.error(
296+
Constant.messages.getString(
297+
"scripts.automation.error.scriptTypeNotSupported",
298+
jobName,
299+
script.getTypeName(),
300+
getName(),
301+
String.join(", ", getSupportedScriptTypes())));
302+
return;
303+
}
304+
305+
if (parameters.getType().equals(ExtensionScript.TYPE_TARGETED)) {
306+
executeScriptWithOutputListener(
307+
script,
308+
progress,
309+
() -> {
310+
URI targetUri = new URI(parameters.getTarget(), true);
311+
SiteNode siteNode =
312+
Model.getSingleton().getSession().getSiteTree().findNode(targetUri);
313+
if (siteNode == null) {
314+
progress.error(
315+
Constant.messages.getString(
316+
"scripts.automation.error.scriptTargetNotFound",
317+
jobName,
318+
parameters.getTarget()));
319+
return;
320+
}
321+
322+
HttpMessage httpMessage = siteNode.getHistoryReference().getHttpMessage();
323+
extScript.invokeTargetedScript(script, httpMessage);
324+
},
325+
(e) -> reportScriptError(progress, jobName, parameters, e));
326+
} else {
327+
runSingleScript(jobName, user, progress, script);
328+
}
329+
}
330+
331+
/** Runs one standalone script (non-targeted). */
332+
private void runSingleScript(
333+
String jobName, User user, AutomationProgress progress, ScriptWrapper script) {
334+
setUserOnZestWrapper(script, user);
335+
executeScriptWithOutputListener(
336+
script,
337+
progress,
338+
() -> extScript.invokeScript(script),
339+
(e) -> reportScriptError(progress, jobName, parameters, e));
340+
}
341+
342+
/**
343+
* Runs the script with output listener setup/teardown and error handling.
344+
*
345+
* @param script the script to execute
346+
* @param progress the automation progress for output
347+
* @param executor the script execution logic
348+
* @param errorHandler the error handler for exceptions
349+
* @return true if execution succeeded, false otherwise
350+
*/
351+
private boolean executeScriptWithOutputListener(
352+
ScriptWrapper script,
353+
AutomationProgress progress,
354+
ScriptExecutor executor,
355+
Consumer<Exception> errorHandler) {
356+
ScriptJobOutputListener scriptJobOutputListener =
357+
new ScriptJobOutputListener(progress, script.getName());
358+
try {
359+
extScript.addScriptOutputListener(scriptJobOutputListener);
360+
executor.execute();
239361
scriptJobOutputListener.flush();
240362

241363
if (script.getLastException() != null) {
242-
reportScriptError(progress, jobName, parameters, script.getLastException());
364+
errorHandler.accept(script.getLastException());
365+
return false;
243366
}
367+
return true;
244368
} catch (Exception e) {
245369
LOGGER.error(e, e);
246-
reportScriptError(progress, jobName, parameters, e);
370+
errorHandler.accept(e);
371+
return false;
247372
} finally {
248373
extScript.removeScriptOutputListener(scriptJobOutputListener);
249374
}
250375
}
251376

377+
/** Script execution logic that may throw. */
378+
@FunctionalInterface
379+
private interface ScriptExecutor {
380+
void execute() throws Exception;
381+
}
382+
252383
private static void reportScriptError(
253384
AutomationProgress progress,
254385
String jobName,
@@ -281,4 +412,80 @@ private void setUserOnZestWrapper(ScriptWrapper script, User user) {
281412
LOGGER.warn("Failed to set user on script wrapper", e);
282413
}
283414
}
415+
416+
/**
417+
* Gets the chain script from Zest via reflection. Returns null if Zest is not loaded or the
418+
* method is missing. Throws if Zest's getChainScript throws (e.g. validation failure); the
419+
* exception message is then reported to the user.
420+
*
421+
* @param scriptWrappers validated chain in order
422+
* @param runName name for the run
423+
* @return chain script to invoke, or null
424+
* @throws Exception when Zest's getChainScript throws (cause is rethrown)
425+
*/
426+
private ScriptWrapper getChainScriptViaReflection(
427+
List<ScriptWrapper> scriptWrappers, String runName) throws Exception {
428+
Object extZest =
429+
Control.getSingleton().getExtensionLoader().getExtension(EXTENSION_ZEST_NAME);
430+
if (extZest == null) {
431+
LOGGER.warn("ExtensionZest not loaded, cannot get chain script");
432+
return null;
433+
}
434+
try {
435+
Method getChainScript =
436+
extZest.getClass().getMethod("getChainScript", List.class, String.class);
437+
return (ScriptWrapper) getChainScript.invoke(extZest, scriptWrappers, runName);
438+
} catch (NoSuchMethodException | IllegalAccessException | SecurityException e) {
439+
LOGGER.warn("Failed to get chain script via ExtensionZest", e);
440+
return null;
441+
} catch (InvocationTargetException e) {
442+
Throwable cause = e.getCause();
443+
if (cause instanceof Error) {
444+
throw (Error) cause;
445+
}
446+
if (cause instanceof Exception) {
447+
throw (Exception) cause;
448+
}
449+
throw new RuntimeException(cause);
450+
}
451+
}
452+
453+
/**
454+
* Validates chain scripts and returns their wrappers.
455+
*
456+
* @param chain script names in order
457+
* @param jobName for error messages
458+
* @param progress for error reporting
459+
* @return validated ScriptWrappers, or null if validation failed
460+
*/
461+
private List<ScriptWrapper> validateChainScripts(
462+
List<String> chain, String jobName, AutomationProgress progress) {
463+
List<ScriptWrapper> scriptWrappers = new ArrayList<>();
464+
465+
for (String scriptName : chain) {
466+
ScriptWrapper script = extScript.getScript(scriptName);
467+
if (script == null) {
468+
progress.error(
469+
Constant.messages.getString(
470+
"scripts.automation.error.chainScriptNotFound",
471+
jobName,
472+
scriptName));
473+
return null;
474+
}
475+
476+
if (!ExtensionScript.TYPE_STANDALONE.equals(script.getTypeName())
477+
|| !ZEST_ENGINE_NAME.equals(script.getEngineName())) {
478+
progress.error(
479+
Constant.messages.getString(
480+
"scripts.automation.error.chainScriptNotZestStandalone",
481+
jobName,
482+
scriptName));
483+
return null;
484+
}
485+
486+
scriptWrappers.add(script);
487+
}
488+
489+
return scriptWrappers;
490+
}
284491
}

addOns/scripts/src/main/java/org/zaproxy/zap/extension/scripts/automation/ui/ScriptJobDialog.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ public String validateFields() {
301301
this.getStringValue(SCRIPT_TARGET_PARAM),
302302
this.getStringValue(SCRIPT_INLINE_PARAM),
303303
this.getStringValue(SCRIPT_CONTEXT_PARAM),
304-
this.getStringValue(SCRIPT_USER_PARAM));
304+
this.getStringValue(SCRIPT_USER_PARAM),
305+
null); // chain parameter - not currently supported in UI dialog
305306
sa = ScriptJob.createScriptAction(params, null);
306307
List<String> issues = sa.verifyParameters(this.getStringValue(NAME_PARAM), params, null);
307308
if (issues.isEmpty()) {

0 commit comments

Comments
 (0)