diff --git a/plugin/src/main/java/org/owasp/benchmarkutils/tools/BenchmarkCrawlerVerification_newv2.java b/plugin/src/main/java/org/owasp/benchmarkutils/tools/BenchmarkCrawlerVerification_newv2.java
new file mode 100644
index 00000000..b73d4e51
--- /dev/null
+++ b/plugin/src/main/java/org/owasp/benchmarkutils/tools/BenchmarkCrawlerVerification_newv2.java
@@ -0,0 +1,581 @@
+/**
+ * OWASP Benchmark Project
+ *
+ *
This file is part of the Open Web Application Security Project (OWASP) Benchmark Project For
+ * details, please see https://owasp.org/www-project-benchmark/.
+ *
+ *
The OWASP Benchmark is free software: you can redistribute it and/or modify it under the terms
+ * of the GNU General Public License as published by the Free Software Foundation, version 2.
+ *
+ *
The OWASP Benchmark is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+ * PURPOSE. See the GNU General Public License for more details.
+ */
+package org.owasp.benchmarkutils.tools;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.CommandLineParser;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequest;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.owasp.benchmarkutils.helpers.TestSuite;
+import org.owasp.benchmarkutils.helpers.Utils;
+
+/**
+ * V2 verification crawler that extends {@link BenchmarkCrawler_newv2} to send both attack and safe
+ * requests per test case and verify whether the exploit succeeded.
+ *
+ *
Inherits the configurable timeout ({@code -T}) and CLI execution support from the parent. Also
+ * supports the {@code -t} timing-threshold flag from the original {@link
+ * BenchmarkCrawlerVerification}.
+ *
+ *
Resolves GitHub issues #3 (timeout) and #1 (command-line crawler/verification).
+ */
+@Mojo(
+ name = "run-verification-crawler-v2",
+ requiresProject = false,
+ defaultPhase = LifecyclePhase.COMPILE)
+public class BenchmarkCrawlerVerification_newv2 extends BenchmarkCrawler_newv2 {
+
+ private static int maxTimeInSeconds = 2;
+ private static boolean isTimingEnabled = false;
+
+ private static final String FILENAME_TIMES_ALL = "crawlerTimes.txt";
+ private static final String FILENAME_TIMES = "crawlerSlowTimes.txt";
+ private static final String FILENAME_NON_DISCRIMINATORY_LOG = "nonDiscriminatoryTestCases.txt";
+ private static final String FILENAME_ERRORS_LOG = "errorTestCases.txt";
+ private static final String FILENAME_UNVERIFIABLE_LOG = "unverifiableTestCases.txt";
+
+ private static String CRAWLER_DATA_DIR = Utils.DATA_DIR;
+
+ SimpleFileLogger tLogger;
+ SimpleFileLogger ndLogger;
+ SimpleFileLogger eLogger;
+ SimpleFileLogger uLogger;
+
+ @Override
+ protected void crawl(TestSuite testSuite) throws Exception {
+ CloseableHttpClient httpclient = createHttpClient();
+ long start = System.currentTimeMillis();
+ List responseInfoList = new ArrayList<>();
+ List httpResults = new ArrayList<>();
+ List cliResults = new ArrayList<>();
+
+ final File FILE_NON_DISCRIMINATORY_LOG =
+ new File(CRAWLER_DATA_DIR, FILENAME_NON_DISCRIMINATORY_LOG);
+ final File FILE_ERRORS_LOG = new File(CRAWLER_DATA_DIR, FILENAME_ERRORS_LOG);
+ final File FILE_TIMES_LOG;
+ if (isTimingEnabled) {
+ FILE_TIMES_LOG = new File(CRAWLER_DATA_DIR, FILENAME_TIMES);
+ } else {
+ FILE_TIMES_LOG = new File(CRAWLER_DATA_DIR, FILENAME_TIMES_ALL);
+ }
+ final File FILE_UNVERIFIABLE_LOG = new File(CRAWLER_DATA_DIR, FILENAME_UNVERIFIABLE_LOG);
+ SimpleFileLogger.setFile("TIMES", FILE_TIMES_LOG);
+ SimpleFileLogger.setFile("NONDISCRIMINATORY", FILE_NON_DISCRIMINATORY_LOG);
+ SimpleFileLogger.setFile("ERRORS", FILE_ERRORS_LOG);
+ SimpleFileLogger.setFile("UNVERIFIABLE", FILE_UNVERIFIABLE_LOG);
+
+ String completionMessage = null;
+
+ try (SimpleFileLogger nl = SimpleFileLogger.getLogger("NONDISCRIMINATORY");
+ SimpleFileLogger el = SimpleFileLogger.getLogger("ERRORS");
+ SimpleFileLogger ul = SimpleFileLogger.getLogger("UNVERIFIABLE");
+ SimpleFileLogger tl = SimpleFileLogger.getLogger("TIMES")) {
+
+ ndLogger = nl;
+ eLogger = el;
+ uLogger = ul;
+ tLogger = tl;
+
+ for (AbstractTestCaseRequest requestTemplate : testSuite.getTestCases()) {
+ boolean isCli = requestTemplate instanceof CommandLineTestCaseRequest;
+
+ ResponseInfo attackPayloadResponseInfo;
+ ResponseInfo safePayloadResponseInfo = null;
+ HttpUriRequest attackRequest = null;
+ HttpUriRequest safeRequest = null;
+
+ if (isCli) {
+ CommandLineTestCaseRequest cliReq =
+ (CommandLineTestCaseRequest) requestTemplate;
+ List attackCmd = cliReq.buildCommand(false);
+ attackPayloadResponseInfo =
+ executeCommand(attackCmd, cliReq.getCommandDir(), networkTimeoutSeconds);
+ responseInfoList.add(attackPayloadResponseInfo);
+ logResponse(attackPayloadResponseInfo, "CMD " + String.join(" ", attackCmd));
+
+ if (!requestTemplate.isUnverifiable()) {
+ List safeCmd = cliReq.buildCommand(true);
+ safePayloadResponseInfo =
+ executeCommand(
+ safeCmd, cliReq.getCommandDir(), networkTimeoutSeconds);
+ responseInfoList.add(safePayloadResponseInfo);
+ logResponse(safePayloadResponseInfo, "CMD " + String.join(" ", safeCmd));
+ }
+ } else {
+ attackRequest = requestTemplate.buildAttackRequest();
+ safeRequest = requestTemplate.buildSafeRequest();
+
+ attackPayloadResponseInfo = sendRequest(httpclient, attackRequest);
+ responseInfoList.add(attackPayloadResponseInfo);
+ logResponse(attackPayloadResponseInfo, attackRequest);
+
+ if (!requestTemplate.isUnverifiable()) {
+ safePayloadResponseInfo = sendRequest(httpclient, safeRequest);
+ responseInfoList.add(safePayloadResponseInfo);
+ logResponse(safePayloadResponseInfo, safeRequest);
+ }
+ }
+
+ TestCaseVerificationResults result =
+ new TestCaseVerificationResults(
+ attackRequest,
+ safeRequest,
+ requestTemplate,
+ attackPayloadResponseInfo,
+ safePayloadResponseInfo);
+
+ if (RegressionTesting.isTestingEnabled) {
+ if (isCli) {
+ verifyCliTestCase(result);
+ cliResults.add(result);
+ } else {
+ handleResponse(result);
+ httpResults.add(result);
+ }
+ } else {
+ if (isCli) cliResults.add(result);
+ else httpResults.add(result);
+ }
+ }
+
+ long stop = System.currentTimeMillis();
+ int seconds = (int) (stop - start) / 1000;
+ Date now = new Date();
+
+ completionMessage =
+ String.format(
+ "Verification crawl ran on %tF % allResults = new ArrayList<>(httpResults);
+ allResults.addAll(cliResults);
+
+ // genFailedTCFile calls printTestCaseDetails which calls printHttpRequest —
+ // that NPEs on null HttpUriRequest. So we only pass HTTP results to it.
+ // Then we supplement the static counters it sets with CLI results so
+ // printCrawlSummary reports accurate totals.
+ RegressionTesting.genFailedTCFile(httpResults, CRAWLER_DATA_DIR);
+ supplementCountsWithCliResults(cliResults);
+
+ printCliFailures(cliResults);
+
+ if (!RegressionTesting.failedTruePositivesList.isEmpty()
+ || !RegressionTesting.failedFalsePositivesList.isEmpty()) {
+ eLogger.println();
+ eLogger.println("== Errors report ==");
+ eLogger.println();
+ }
+
+ if (!RegressionTesting.failedTruePositivesList.isEmpty()) {
+ eLogger.printf(
+ "== True Positive Test Cases with Errors [%d of %d] ==%n",
+ RegressionTesting.failedTruePositives,
+ RegressionTesting.truePositives);
+ eLogger.println();
+ for (AbstractTestCaseRequest request :
+ RegressionTesting.failedTruePositivesList.keySet()) {
+ eLogger.printf(
+ "%s: %s%n",
+ request.getName(),
+ RegressionTesting.failedTruePositivesList.get(request));
+ }
+ }
+
+ if (!RegressionTesting.failedFalsePositivesList.isEmpty()) {
+ if (!RegressionTesting.failedTruePositivesList.isEmpty()) {
+ eLogger.println();
+ }
+ eLogger.printf(
+ "== False Positive Test Cases with Errors [%d of %d] ==%n",
+ RegressionTesting.failedFalsePositives,
+ RegressionTesting.falsePositives);
+ eLogger.println();
+ for (AbstractTestCaseRequest request :
+ RegressionTesting.failedFalsePositivesList.keySet()) {
+ eLogger.printf(
+ "%s: %s%n",
+ request.getName(),
+ RegressionTesting.failedFalsePositivesList.get(request));
+ }
+ }
+ }
+ }
+
+ if (FILE_NON_DISCRIMINATORY_LOG.length() > 0) {
+ System.out.printf(
+ "Details of non-discriminatory test cases written to: %s%n",
+ FILE_NON_DISCRIMINATORY_LOG);
+ }
+ if (FILE_ERRORS_LOG.length() > 0) {
+ System.out.printf(
+ "Details of errors/exceptions in test cases written to: %s%n", FILE_ERRORS_LOG);
+ }
+ if (FILE_UNVERIFIABLE_LOG.length() > 0) {
+ System.out.printf(
+ "Details of unverifiable test cases written to: %s%n", FILE_UNVERIFIABLE_LOG);
+ }
+ System.out.printf("Test case time measurements written to: %s%n", FILE_TIMES_LOG);
+
+ List allResults = new ArrayList<>(httpResults);
+ allResults.addAll(cliResults);
+ RegressionTesting.printCrawlSummary(allResults);
+ System.out.println();
+ System.out.println(completionMessage);
+ }
+
+ /**
+ * Verify a CLI test case using {@link RegressionTesting#verifyResponse} directly. We avoid
+ * calling {@link RegressionTesting#verifyTestCase} because it delegates to {@code
+ * printTestCaseDetails} which calls {@code printHttpRequest} on the attackRequest/safeRequest
+ * fields — those are null for CLI test cases.
+ */
+ private void verifyCliTestCase(TestCaseVerificationResults result)
+ throws FileNotFoundException, LoggerConfigurationException {
+
+ AbstractTestCaseRequest requestTemplate = result.getRequestTemplate();
+
+ result.setUnverifiable(false);
+ result.setDeclaredUnverifiable(false);
+
+ if (requestTemplate.isUnverifiable()) {
+ result.setUnverifiable(true);
+ result.setDeclaredUnverifiable(true);
+ uLogger.printf("UNVERIFIABLE (declared CLI): %s%n", requestTemplate.getName());
+ } else if (requestTemplate.getAttackSuccessString() == null) {
+ result.setUnverifiable(true);
+ result.setDeclaredUnverifiable(false);
+ uLogger.printf("UNVERIFIABLE (undeclared CLI): %s%n", requestTemplate.getName());
+ }
+
+ if (!result.isUnverifiable()) {
+ boolean isAttackValueVerified =
+ RegressionTesting.verifyResponse(
+ result.getResponseToAttackValue().getResponseString(),
+ requestTemplate.getAttackSuccessString(),
+ requestTemplate.getAttackSuccessStringPresent());
+
+ boolean isSafeValueVerified = false;
+ if (result.getResponseToSafeValue() != null) {
+ isSafeValueVerified =
+ RegressionTesting.verifyResponse(
+ result.getResponseToSafeValue().getResponseString(),
+ requestTemplate.getAttackSuccessString(),
+ requestTemplate.getAttackSuccessStringPresent());
+ }
+
+ if (requestTemplate.isVulnerability()) {
+ if (isAttackValueVerified) {
+ result.setPassed(true);
+ if (isSafeValueVerified) {
+ ndLogger.printf(
+ "Non-discriminatory true positive CLI test %s: "
+ + "attack-success-string found in both safe and attack "
+ + "responses.%n",
+ requestTemplate.getName());
+ }
+ } else {
+ result.setPassed(false);
+ }
+ } else {
+ if (isAttackValueVerified) {
+ result.setPassed(false);
+ } else {
+ result.setPassed(true);
+ if (isSafeValueVerified) {
+ ndLogger.printf(
+ "Non-discriminatory false positive CLI test %s: "
+ + "attack-success-string found in safe response.%n",
+ requestTemplate.getName());
+ }
+ }
+ }
+ }
+
+ // Error detection (mirrors RegressionTesting.findErrors)
+ List reasons = new ArrayList<>();
+ findCliErrors(result.getResponseToAttackValue(), "Attack value", reasons);
+ findCliErrors(result.getResponseToSafeValue(), "Safe value", reasons);
+ boolean hasErrors = !reasons.isEmpty();
+ String compositeReason = "\t- " + String.join(", ", reasons);
+
+ if (requestTemplate.isVulnerability()) {
+ RegressionTesting.truePositives++;
+ if (hasErrors) {
+ RegressionTesting.failedTruePositives++;
+ RegressionTesting.failedTruePositivesList.put(requestTemplate, compositeReason);
+ }
+ } else {
+ RegressionTesting.falsePositives++;
+ if (hasErrors) {
+ RegressionTesting.failedFalsePositives++;
+ RegressionTesting.failedFalsePositivesList.put(requestTemplate, compositeReason);
+ }
+ }
+ }
+
+ /**
+ * Supplement the static counters in {@link RegressionTesting} that {@code genFailedTCFile} sets
+ * (totalCount, passedCount, failedCount, etc.) with CLI test case results. This is necessary
+ * because we only pass HTTP results to {@code genFailedTCFile} to avoid NPEs.
+ */
+ private static void supplementCountsWithCliResults(
+ List cliResults) {
+ for (TestCaseVerificationResults result : cliResults) {
+ RegressionTesting.totalCount++;
+ if (result.isUnverifiable()) {
+ if (result.isDeclaredUnverifiable()) {
+ RegressionTesting.declaredUnverifiable++;
+ } else {
+ RegressionTesting.undeclaredUnverifiable++;
+ }
+ } else {
+ RegressionTesting.verifiedCount++;
+ if (result.isPassed()) {
+ if (result.getRequestTemplate().isVulnerability()) {
+ RegressionTesting.truePositivePassedCount++;
+ } else {
+ RegressionTesting.falsePositivePassedCount++;
+ }
+ } else {
+ if (result.getRequestTemplate().isVulnerability()) {
+ RegressionTesting.truePositiveFailedCount++;
+ } else {
+ RegressionTesting.falsePositiveFailedCount++;
+ }
+ }
+ }
+ }
+ }
+
+ private static void findCliErrors(
+ ResponseInfo responseInfo, String prefix, List reasons) {
+ if (responseInfo != null) {
+ if (responseInfo.getStatusCode() != 0) {
+ reasons.add(prefix + " exit code: " + responseInfo.getStatusCode());
+ }
+ if (responseInfo.getResponseString().toLowerCase().contains("error")) {
+ reasons.add(prefix + " output contains: error");
+ } else if (responseInfo.getResponseString().toLowerCase().contains("exception")) {
+ reasons.add(prefix + " output contains: exception");
+ }
+ }
+ }
+
+ private void printCliFailures(List cliResults) {
+ for (TestCaseVerificationResults result : cliResults) {
+ if (!result.isUnverifiable() && !result.isPassed()) {
+ AbstractTestCaseRequest req = result.getRequestTemplate();
+ String msg =
+ String.format(
+ "FAILURE: %s positive %s CLI test %s%n",
+ req.isVulnerability() ? "True" : "False",
+ req.getCategory(),
+ req.getName());
+ System.out.print(msg);
+ eLogger.print(msg);
+
+ eLogger.printf(
+ " Attack output (exit %d): %s%n",
+ result.getResponseToAttackValue().getStatusCode(),
+ truncate(result.getResponseToAttackValue().getResponseString(), 500));
+ if (result.getResponseToSafeValue() != null) {
+ eLogger.printf(
+ " Safe output (exit %d): %s%n",
+ result.getResponseToSafeValue().getStatusCode(),
+ truncate(result.getResponseToSafeValue().getResponseString(), 500));
+ }
+ String negated =
+ req.getAttackSuccessStringPresent() ? "" : "Failure ";
+ eLogger.printf(
+ " Attack success %sindicator: -->%s<--%n",
+ negated, req.getAttackSuccessString());
+ eLogger.printf("----------------------------------------------------------%n%n");
+ }
+ }
+ }
+
+ private static String truncate(String s, int maxLen) {
+ if (s == null) return "null";
+ return s.length() <= maxLen ? s : s.substring(0, maxLen) + "... [truncated]";
+ }
+
+ private static void handleResponse(TestCaseVerificationResults result)
+ throws FileNotFoundException, LoggerConfigurationException {
+ RegressionTesting.verifyTestCase(result);
+ }
+
+ private void logResponse(ResponseInfo responseInfo, HttpUriRequest request) throws IOException {
+ String outputString =
+ String.format(
+ "--> (%d : %d sec)%n",
+ responseInfo.getStatusCode(), responseInfo.getTimeInSeconds());
+ try {
+ String requestLine = request.getMethod() + " " + request.getUri();
+ if (isTimingEnabled) {
+ if (responseInfo.getTimeInSeconds() >= maxTimeInSeconds) {
+ tLogger.println(requestLine);
+ tLogger.println(outputString);
+ }
+ } else {
+ tLogger.println(requestLine);
+ tLogger.println(outputString);
+ }
+ } catch (URISyntaxException e) {
+ String errMsg =
+ request.getMethod() + " COULDN'T LOG URI due to URISyntaxException";
+ tLogger.println(errMsg);
+ tLogger.println(outputString);
+ System.out.println(errMsg);
+ e.printStackTrace();
+ }
+ }
+
+ private void logResponse(ResponseInfo responseInfo, String commandDescription) {
+ String outputString =
+ String.format(
+ "--> (exit %d : %d sec)%n",
+ responseInfo.getStatusCode(), responseInfo.getTimeInSeconds());
+ if (isTimingEnabled) {
+ if (responseInfo.getTimeInSeconds() >= maxTimeInSeconds) {
+ tLogger.println(commandDescription);
+ tLogger.println(outputString);
+ }
+ } else {
+ tLogger.println(commandDescription);
+ tLogger.println(outputString);
+ }
+ }
+
+ @Override
+ protected void processCommandLineArgs(String[] args) {
+ File defaultAttackCrawlerFile = new File(Utils.DATA_DIR, "benchmark-attack-http.xml");
+ if (defaultAttackCrawlerFile.exists()) {
+ setCrawlerFile(defaultAttackCrawlerFile.getPath());
+ }
+
+ RegressionTesting.isTestingEnabled = true;
+
+ CommandLineParser parser = new DefaultParser();
+ HelpFormatter formatter = new HelpFormatter();
+
+ Options options = new Options();
+ options.addOption(
+ Option.builder("f")
+ .longOpt("file")
+ .desc("a TESTSUITE-attack-http.xml file")
+ .hasArg()
+ .required()
+ .build());
+ options.addOption(Option.builder("h").longOpt("help").desc("Usage").build());
+ options.addOption(
+ Option.builder("n")
+ .longOpt("name")
+ .desc("testcase name (e.g. BenchmarkTestCase00025)")
+ .hasArg()
+ .build());
+ options.addOption(
+ Option.builder("t")
+ .longOpt("time")
+ .desc("testcase timing threshold (in seconds) for slow-request log")
+ .hasArg()
+ .type(Number.class)
+ .build());
+ options.addOption(
+ Option.builder("T")
+ .longOpt("timeout")
+ .desc(
+ "Response timeout in seconds per request."
+ + " 0 = no timeout (default)."
+ + " Example: -T 300 for 5 minutes.")
+ .hasArg()
+ .type(Number.class)
+ .build());
+
+ try {
+ CommandLine line = parser.parse(options, args);
+
+ if (line.hasOption("f")) {
+ setCrawlerFile(line.getOptionValue("f"));
+ CRAWLER_DATA_DIR = this.theCrawlerFile.getParent() + File.separator;
+ }
+ if (line.hasOption("h")) {
+ formatter.printHelp("BenchmarkCrawlerVerification_newv2", options, true);
+ }
+ if (line.hasOption("n")) {
+ selectedTestCaseName = line.getOptionValue("n");
+ }
+ if (line.hasOption("t")) {
+ maxTimeInSeconds = ((Number) line.getParsedOptionValue("t")).intValue();
+ isTimingEnabled = true;
+ }
+ if (line.hasOption("T")) {
+ networkTimeoutSeconds =
+ ((Number) line.getParsedOptionValue("T")).longValue();
+ if (networkTimeoutSeconds < 0) {
+ System.out.println(
+ "WARNING: Negative timeout value ignored, using 0 (no timeout).");
+ networkTimeoutSeconds = 0;
+ }
+ if (networkTimeoutSeconds > 0) {
+ System.out.printf(
+ "Response timeout set to %d seconds.%n", networkTimeoutSeconds);
+ }
+ }
+ } catch (ParseException e) {
+ formatter.printHelp("BenchmarkCrawlerVerification_newv2", options);
+ throw new RuntimeException("Error parsing arguments: ", e);
+ }
+ }
+
+ @Override
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ if (thisInstance == null) thisInstance = this;
+
+ if (null == this.crawlerFile) {
+ System.out.println("ERROR: An attack crawlerFile parameter must be specified.");
+ System.exit(-1);
+ } else {
+ String[] mainArgs = {"-f", this.crawlerFile};
+ main(mainArgs);
+ }
+ }
+
+ public static void main(String[] args) {
+ if (thisInstance == null) {
+ thisInstance = new BenchmarkCrawlerVerification_newv2();
+ }
+ thisInstance.processCommandLineArgs(args);
+ thisInstance.load();
+ thisInstance.run();
+ }
+}
diff --git a/plugin/src/main/java/org/owasp/benchmarkutils/tools/BenchmarkCrawler_newv2.java b/plugin/src/main/java/org/owasp/benchmarkutils/tools/BenchmarkCrawler_newv2.java
new file mode 100644
index 00000000..90f72009
--- /dev/null
+++ b/plugin/src/main/java/org/owasp/benchmarkutils/tools/BenchmarkCrawler_newv2.java
@@ -0,0 +1,376 @@
+/**
+ * OWASP Benchmark Project
+ *
+ * This file is part of the Open Web Application Security Project (OWASP) Benchmark Project For
+ * details, please see https://owasp.org/www-project-benchmark/.
+ *
+ *
The OWASP Benchmark is free software: you can redistribute it and/or modify it under the terms
+ * of the GNU General Public License as published by the Free Software Foundation, version 2.
+ *
+ *
The OWASP Benchmark is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+ * PURPOSE. See the GNU General Public License for more details.
+ */
+package org.owasp.benchmarkutils.tools;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyManagementException;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.Unmarshaller;
+import javax.xml.bind.helpers.DefaultValidationEventHandler;
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.CommandLineParser;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.apache.commons.lang.time.StopWatch;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequest;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
+import org.apache.hc.client5.http.ssl.TrustSelfSignedStrategy;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.ssl.SSLContextBuilder;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.eclipse.persistence.jaxb.JAXBContextFactory;
+import org.owasp.benchmarkutils.helpers.Categories;
+import org.owasp.benchmarkutils.helpers.TestSuite;
+import org.owasp.benchmarkutils.score.BenchmarkScore;
+
+/**
+ * V2 crawler that adds two capabilities over {@link BenchmarkCrawler}:
+ *
+ *
+ * - Configurable timeout ({@code -T / --timeout}): response timeout in seconds, disabled
+ * by default (0 = wait indefinitely). Resolves GitHub issue #3.
+ *
- Command-line test case execution: test cases with {@code tcType="CLI"} are executed
+ * as subprocesses via {@link ProcessBuilder} instead of HTTP. Resolves GitHub issue #1.
+ *
+ *
+ * Existing HTTP test suites work identically — this is a drop-in replacement.
+ */
+@Mojo(name = "run-crawler-v2", requiresProject = false, defaultPhase = LifecyclePhase.COMPILE)
+public class BenchmarkCrawler_newv2 extends BenchmarkCrawler {
+
+ private static final long CONNECT_TIMEOUT_SECONDS = 30;
+
+ /**
+ * Response timeout in seconds. 0 means disabled (wait indefinitely). Set via {@code -T} CLI
+ * flag.
+ */
+ protected long networkTimeoutSeconds = 0;
+
+ @Override
+ protected void crawl(TestSuite testSuite) throws Exception {
+ CloseableHttpClient httpclient = createHttpClient();
+ long start = System.currentTimeMillis();
+
+ for (AbstractTestCaseRequest requestTemplate : testSuite.getTestCases()) {
+ if (requestTemplate instanceof CommandLineTestCaseRequest) {
+ CommandLineTestCaseRequest cliRequest =
+ (CommandLineTestCaseRequest) requestTemplate;
+ List command = cliRequest.buildCommand(true);
+ ResponseInfo responseInfo =
+ executeCommand(command, cliRequest.getCommandDir(), networkTimeoutSeconds);
+ logCommandResponse(command, responseInfo);
+ } else {
+ HttpUriRequest request = requestTemplate.buildSafeRequest();
+ sendRequest(httpclient, request);
+ }
+ }
+
+ long stop = System.currentTimeMillis();
+ int seconds = (int) (stop - start) / 1000;
+ Date now = new Date();
+ System.out.printf(
+ "Crawl ran on %tF %Connect and connection-request timeouts are always {@value #CONNECT_TIMEOUT_SECONDS}
+ * seconds. Response timeout is controlled by {@link #networkTimeoutSeconds}: 0 means no timeout
+ * (indefinite wait).
+ */
+ protected CloseableHttpClient createHttpClient()
+ throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException {
+
+ SSLContext sslContext =
+ SSLContextBuilder.create().loadTrustMaterial(new TrustSelfSignedStrategy()).build();
+
+ HostnameVerifier allowAllHosts = new NoopHostnameVerifier();
+ SSLConnectionSocketFactory connectionFactory =
+ new SSLConnectionSocketFactory(sslContext, allowAllHosts);
+
+ HttpClientConnectionManager cm =
+ PoolingHttpClientConnectionManagerBuilder.create()
+ .setSSLSocketFactory(connectionFactory)
+ .build();
+
+ RequestConfig.Builder configBuilder =
+ RequestConfig.custom()
+ .setConnectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .setConnectionRequestTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+
+ if (networkTimeoutSeconds > 0) {
+ configBuilder.setResponseTimeout(networkTimeoutSeconds, TimeUnit.SECONDS);
+ } else {
+ configBuilder.setResponseTimeout(Timeout.DISABLED);
+ }
+
+ RequestConfig config = configBuilder.build();
+
+ HttpClientBuilder builder =
+ HttpClients.custom()
+ .setDefaultRequestConfig(config)
+ .setConnectionManager(cm);
+
+ String pHost = System.getProperty("proxyHost");
+ String pPort = System.getProperty("proxyPort");
+ if (pHost != null && pPort != null) {
+ builder.setProxy(new HttpHost(pHost, Integer.parseInt(pPort)));
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Execute a command-line test case as a subprocess.
+ *
+ * @param command the executable and its arguments.
+ * @param workingDir working directory (null = inherit from JVM).
+ * @param timeoutSeconds max seconds to wait for the process (0 = no limit).
+ * @return a {@link ResponseInfo} where responseString is stdout+stderr, statusCode is the exit
+ * code (-1 on timeout or error), and timeInSeconds is wall-clock elapsed time.
+ */
+ protected static ResponseInfo executeCommand(
+ List command, String workingDir, long timeoutSeconds) {
+
+ ResponseInfo responseInfo = new ResponseInfo();
+ StopWatch watch = new StopWatch();
+
+ System.out.println("CMD " + String.join(" ", command));
+
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.redirectErrorStream(true);
+ if (workingDir != null && !workingDir.trim().isEmpty()) {
+ pb.directory(new File(workingDir));
+ }
+
+ watch.start();
+ try {
+ Process process = pb.start();
+ String output = readStream(process.getInputStream());
+
+ boolean finished;
+ if (timeoutSeconds > 0) {
+ finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ output += "\n[TIMEOUT after " + timeoutSeconds + " seconds]";
+ System.out.println("TIMEOUT: Process killed after " + timeoutSeconds + "s");
+ }
+ } else {
+ process.waitFor();
+ finished = true;
+ }
+
+ responseInfo.setResponseString(output);
+ responseInfo.setStatusCode(finished ? process.exitValue() : -1);
+ } catch (IOException e) {
+ System.out.println("ERROR: Failed to execute command: " + e.getMessage());
+ e.printStackTrace();
+ responseInfo.setResponseString("ERROR: " + e.getMessage());
+ responseInfo.setStatusCode(-1);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ responseInfo.setResponseString("INTERRUPTED");
+ responseInfo.setStatusCode(-1);
+ }
+ watch.stop();
+
+ int seconds = (int) watch.getTime() / 1000;
+ responseInfo.setTimeInSeconds(seconds);
+ System.out.printf("--> (exit %d : %d sec)%n", responseInfo.getStatusCode(), seconds);
+ return responseInfo;
+ }
+
+ private static String readStream(InputStream stream) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ try (BufferedReader reader =
+ new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (sb.length() > 0) sb.append('\n');
+ sb.append(line);
+ }
+ }
+ return sb.toString();
+ }
+
+ private void logCommandResponse(List command, ResponseInfo responseInfo) {
+ // stdout logging already handled in executeCommand(); nothing extra needed for basic crawl.
+ }
+
+ /**
+ * Load test suite from XML, using an extended JAXB context that recognizes {@code tcType="CLI"}
+ * test cases via {@link CommandLineTestCaseRequest}.
+ */
+ @Override
+ void load() {
+ try {
+ InputStream categoriesFileStream =
+ BenchmarkScore.class
+ .getClassLoader()
+ .getResourceAsStream(Categories.FILENAME);
+ new Categories(categoriesFileStream);
+
+ this.testSuite = parseHttpFileWithCliSupport(this.theCrawlerFile);
+
+ Collections.sort(
+ this.testSuite.getTestCases(), AbstractTestCaseRequest.getNameComparator());
+
+ if (selectedTestCaseName != null) {
+ for (AbstractTestCaseRequest request : this.testSuite.getTestCases()) {
+ if (request.getName().equals(selectedTestCaseName)) {
+ List requests = new ArrayList<>();
+ requests.add(request);
+ this.testSuite = new TestSuite();
+ this.testSuite.setTestCases(requests);
+ break;
+ }
+ }
+ }
+ } catch (Exception e) {
+ System.out.println(
+ "ERROR: Problem with specified crawler file: " + this.theCrawlerFile);
+ e.printStackTrace();
+ System.exit(-1);
+ }
+ }
+
+ /**
+ * Parse an XML crawler file using a JAXB context that includes {@link
+ * CommandLineTestCaseRequest} in addition to the standard HTTP request types.
+ */
+ static TestSuite parseHttpFileWithCliSupport(File file) throws Exception {
+ JAXBContext context =
+ JAXBContextFactory.createContext(
+ new Class[] {TestSuite.class, CommandLineTestCaseRequest.class}, null);
+ Unmarshaller unmarshaller = context.createUnmarshaller();
+ unmarshaller.setEventHandler(new DefaultValidationEventHandler());
+ return (TestSuite) unmarshaller.unmarshal(new FileReader(file));
+ }
+
+ @Override
+ protected void processCommandLineArgs(String[] args) {
+ CommandLineParser parser = new DefaultParser();
+ HelpFormatter formatter = new HelpFormatter();
+
+ Options options = new Options();
+ options.addOption(
+ Option.builder("f")
+ .longOpt("file")
+ .desc("a TESTSUITE-crawler-http.xml file")
+ .hasArg()
+ .build());
+ options.addOption(
+ Option.builder("n")
+ .longOpt("name")
+ .desc("testcase name (e.g. BenchmarkTestCase00025)")
+ .hasArg()
+ .build());
+ options.addOption(Option.builder("h").longOpt("help").desc("Usage").build());
+ options.addOption(
+ Option.builder("T")
+ .longOpt("timeout")
+ .desc(
+ "Response timeout in seconds per request."
+ + " 0 = no timeout (default)."
+ + " Example: -T 300 for 5 minutes.")
+ .hasArg()
+ .type(Number.class)
+ .build());
+
+ try {
+ CommandLine line = parser.parse(options, args);
+
+ if (line.hasOption("f")) {
+ setCrawlerFile(line.getOptionValue("f"));
+ }
+ if (line.hasOption("h")) {
+ formatter.printHelp("BenchmarkCrawler_newv2", options, true);
+ }
+ if (line.hasOption("n")) {
+ selectedTestCaseName = line.getOptionValue("n");
+ }
+ if (line.hasOption("T")) {
+ networkTimeoutSeconds =
+ ((Number) line.getParsedOptionValue("T")).longValue();
+ if (networkTimeoutSeconds < 0) {
+ System.out.println(
+ "WARNING: Negative timeout value ignored, using 0 (no timeout).");
+ networkTimeoutSeconds = 0;
+ }
+ if (networkTimeoutSeconds > 0) {
+ System.out.printf(
+ "Response timeout set to %d seconds.%n", networkTimeoutSeconds);
+ }
+ }
+ } catch (ParseException e) {
+ formatter.printHelp("BenchmarkCrawler_newv2", options);
+ throw new RuntimeException("Error parsing arguments: ", e);
+ }
+ }
+
+ @Override
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ if (thisInstance == null) thisInstance = this;
+
+ if (null == this.crawlerFile) {
+ System.out.println("ERROR: A crawlerFile parameter must be specified.");
+ System.exit(-1);
+ } else {
+ String[] mainArgs = {"-f", this.crawlerFile};
+ main(mainArgs);
+ }
+ }
+
+ public static void main(String[] args) {
+ if (thisInstance == null) {
+ thisInstance = new BenchmarkCrawler_newv2();
+ }
+ thisInstance.processCommandLineArgs(args);
+ thisInstance.load();
+ thisInstance.run();
+ }
+}
diff --git a/plugin/src/main/java/org/owasp/benchmarkutils/tools/CommandLineTestCaseRequest.java b/plugin/src/main/java/org/owasp/benchmarkutils/tools/CommandLineTestCaseRequest.java
new file mode 100644
index 00000000..c95e3c2e
--- /dev/null
+++ b/plugin/src/main/java/org/owasp/benchmarkutils/tools/CommandLineTestCaseRequest.java
@@ -0,0 +1,135 @@
+/**
+ * OWASP Benchmark Project
+ *
+ * This file is part of the Open Web Application Security Project (OWASP) Benchmark Project For
+ * details, please see https://owasp.org/www-project-benchmark/.
+ *
+ *
The OWASP Benchmark is free software: you can redistribute it and/or modify it under the terms
+ * of the GNU General Public License as published by the Free Software Foundation, version 2.
+ *
+ *
The OWASP Benchmark is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+ * PURPOSE. See the GNU General Public License for more details.
+ */
+package org.owasp.benchmarkutils.tools;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import javax.xml.bind.annotation.XmlAttribute;
+import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
+import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue;
+import org.owasp.benchmarkutils.helpers.RequestVariable;
+
+/**
+ * A test case request that executes a command-line program instead of making an HTTP request. Used
+ * for benchmarking non-web applications (e.g., Python scripts, CLI tools).
+ *
+ *
In the test suite XML, use {@code tcType="CLI"} to select this request type:
+ *
+ *
{@code
+ *
+ *
+ *
+ * }
+ *
+ * The {@code formparam} elements reuse the existing {@link RequestVariable} attack/safe switching
+ * mechanism: their current values are appended as {@code --name value} arguments to the command.
+ */
+@XmlDiscriminatorValue("CLI")
+public class CommandLineTestCaseRequest extends AbstractTestCaseRequest {
+
+ private String command;
+ private String commandArgs;
+ private String commandDir;
+
+ public CommandLineTestCaseRequest() {}
+
+ @XmlAttribute(name = "tcCommand")
+ public String getCommand() {
+ return this.command;
+ }
+
+ public void setCommand(String command) {
+ this.command = command;
+ }
+
+ @XmlAttribute(name = "tcCommandArgs")
+ public String getCommandArgs() {
+ return this.commandArgs;
+ }
+
+ public void setCommandArgs(String commandArgs) {
+ this.commandArgs = commandArgs;
+ }
+
+ @XmlAttribute(name = "tcCommandDir")
+ public String getCommandDir() {
+ return this.commandDir;
+ }
+
+ public void setCommandDir(String commandDir) {
+ this.commandDir = commandDir;
+ }
+
+ /**
+ * Build the command line for execution.
+ *
+ *
Switches all {@link RequestVariable}s to safe or attack mode, then constructs the full
+ * argument list: the executable, any base arguments, and each form parameter as {@code --name
+ * value}.
+ *
+ * @param isSafe true for the safe (control) run, false for the attack run.
+ * @return the command and arguments as a list suitable for {@link ProcessBuilder}.
+ */
+ public List buildCommand(boolean isSafe) {
+ setSafe(isSafe);
+
+ List cmd = new ArrayList<>();
+ cmd.add(command);
+
+ if (commandArgs != null && !commandArgs.trim().isEmpty()) {
+ Collections.addAll(cmd, commandArgs.trim().split("\\s+"));
+ }
+
+ for (RequestVariable param : getFormParams()) {
+ cmd.add("--" + param.getName());
+ cmd.add(param.getValue());
+ }
+
+ return cmd;
+ }
+
+ /**
+ * Returns an unmodifiable view of the command that would be executed. Useful for logging without
+ * side effects on the safe/attack state.
+ */
+ public List getLastBuiltCommand(boolean isSafe) {
+ return Collections.unmodifiableList(buildCommand(isSafe));
+ }
+
+ // --- HTTP abstract method no-ops (required by AbstractTestCaseRequest) ---
+
+ @Override
+ void buildBodyParameters(HttpUriRequestBase request) {}
+
+ @Override
+ void buildCookies(HttpUriRequestBase request) {}
+
+ @Override
+ void buildHeaders(HttpUriRequestBase request) {}
+
+ @Override
+ void buildQueryString() {
+ setQuery("");
+ }
+
+ @Override
+ HttpUriRequestBase createRequestInstance(String URL) {
+ return null;
+ }
+}
diff --git a/plugin/src/test/java/org/owasp/benchmarkutils/tools/CommandLineTestCaseRequestTest.java b/plugin/src/test/java/org/owasp/benchmarkutils/tools/CommandLineTestCaseRequestTest.java
new file mode 100644
index 00000000..e45ad02f
--- /dev/null
+++ b/plugin/src/test/java/org/owasp/benchmarkutils/tools/CommandLineTestCaseRequestTest.java
@@ -0,0 +1,115 @@
+/**
+ * OWASP Benchmark Project
+ *
+ * This file is part of the Open Web Application Security Project (OWASP) Benchmark Project For
+ * details, please see https://owasp.org/www-project-benchmark/.
+ *
+ *
The OWASP Benchmark is free software: you can redistribute it and/or modify it under the terms
+ * of the GNU General Public License as published by the Free Software Foundation, version 2.
+ *
+ *
The OWASP Benchmark is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+ * PURPOSE. See the GNU General Public License for more details.
+ */
+package org.owasp.benchmarkutils.tools;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.owasp.benchmarkutils.helpers.RequestVariable;
+
+public class CommandLineTestCaseRequestTest {
+
+ @Test
+ void buildCommandWithNoArgs() {
+ CommandLineTestCaseRequest req = new CommandLineTestCaseRequest();
+ req.setCommand("python3");
+
+ List cmd = req.buildCommand(true);
+
+ assertEquals(List.of("python3"), cmd);
+ }
+
+ @Test
+ void buildCommandWithBaseArgs() {
+ CommandLineTestCaseRequest req = new CommandLineTestCaseRequest();
+ req.setCommand("python3");
+ req.setCommandArgs("test001.py --verbose");
+
+ List cmd = req.buildCommand(true);
+
+ assertEquals(List.of("python3", "test001.py", "--verbose"), cmd);
+ }
+
+ @Test
+ void buildCommandWithFormParamsInSafeMode() {
+ CommandLineTestCaseRequest req = new CommandLineTestCaseRequest();
+ req.setCommand("python3");
+ req.setCommandArgs("test001.py");
+
+ RequestVariable param =
+ new RequestVariable("input", "hello", "input", "' OR 1=1 --", "input", "hello");
+ req.setFormParams(Arrays.asList(param));
+
+ List cmd = req.buildCommand(true);
+
+ assertEquals(
+ List.of("python3", "test001.py", "--input", "hello"),
+ cmd,
+ "Safe mode should use the safe value");
+ }
+
+ @Test
+ void buildCommandWithFormParamsInAttackMode() {
+ CommandLineTestCaseRequest req = new CommandLineTestCaseRequest();
+ req.setCommand("python3");
+ req.setCommandArgs("test001.py");
+
+ RequestVariable param =
+ new RequestVariable("input", "hello", "input", "' OR 1=1 --", "input", "hello");
+ req.setFormParams(Arrays.asList(param));
+
+ List cmd = req.buildCommand(false);
+
+ assertEquals(
+ List.of("python3", "test001.py", "--input", "' OR 1=1 --"),
+ cmd,
+ "Attack mode should use the attack value");
+ }
+
+ @Test
+ void buildCommandWithMultipleParams() {
+ CommandLineTestCaseRequest req = new CommandLineTestCaseRequest();
+ req.setCommand("app");
+
+ RequestVariable p1 =
+ new RequestVariable("user", "admin", "user", "root", "user", "admin");
+ RequestVariable p2 =
+ new RequestVariable("pass", "safe123", "pass", "' DROP TABLE--", "pass", "safe123");
+ req.setFormParams(Arrays.asList(p1, p2));
+
+ List cmd = req.buildCommand(true);
+
+ assertEquals(
+ List.of("app", "--user", "admin", "--pass", "safe123"),
+ cmd);
+ }
+
+ @Test
+ void getLastBuiltCommandReturnsUnmodifiableList() {
+ CommandLineTestCaseRequest req = new CommandLineTestCaseRequest();
+ req.setCommand("echo");
+
+ List cmd = req.getLastBuiltCommand(true);
+
+ try {
+ cmd.add("injected");
+ throw new AssertionError("List should be unmodifiable");
+ } catch (UnsupportedOperationException expected) {
+ // correct behavior
+ }
+ }
+}