From ffe95996764a2dee7eec563db8ba9d825a71354a Mon Sep 17 00:00:00 2001 From: ricekot Date: Wed, 19 Nov 2025 23:40:26 +0530 Subject: [PATCH] openapi: Add Swagger Secret Detector Script Signed-off-by: ricekot --- addOns/openapi/CHANGELOG.md | 3 +- addOns/openapi/openapi.gradle.kts | 16 + .../scripts/ExtensionOpenApiScripts.java | 160 ++++++++ .../resources/help/contents/openapi.html | 12 + .../openapi/resources/Messages.properties | 5 + .../scripts/active/SwaggerSecretDetector.js | 380 ++++++++++++++++++ .../SwaggerSecretDetectorScriptUnitTest.java | 285 +++++++++++++ 7 files changed, 860 insertions(+), 1 deletion(-) create mode 100644 addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/scripts/ExtensionOpenApiScripts.java create mode 100644 addOns/openapi/src/main/zapHomeFiles/scripts/scripts/active/SwaggerSecretDetector.js create mode 100644 addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/scripts/SwaggerSecretDetectorScriptUnitTest.java diff --git a/addOns/openapi/CHANGELOG.md b/addOns/openapi/CHANGELOG.md index 78e6604539d..a9731292509 100644 --- a/addOns/openapi/CHANGELOG.md +++ b/addOns/openapi/CHANGELOG.md @@ -4,7 +4,8 @@ All notable changes to this add-on will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased - +### Added +- Swagger Secret Detector Script Scan Rule. ## [47] - 2025-11-04 ### Changed diff --git a/addOns/openapi/openapi.gradle.kts b/addOns/openapi/openapi.gradle.kts index 2e6742e79eb..2a21bfa1fa6 100644 --- a/addOns/openapi/openapi.gradle.kts +++ b/addOns/openapi/openapi.gradle.kts @@ -37,6 +37,19 @@ zapAddOn { } } } + register("org.zaproxy.zap.extension.openapi.scripts.ExtensionOpenApiScripts") { + classnames { + allowed.set(listOf("org.zaproxy.zap.extension.openapi.scripts")) + } + dependencies { + addOns { + register("scripts") { + version.set(">=45.15.0") + } + register("graaljs") + } + } + } } dependencies { addOns { @@ -86,6 +99,9 @@ dependencies { implementation(libs.log4j.slf4j2) testImplementation(parent!!.childProjects.get("commonlib")!!.sourceSets.test.get().output) + testImplementation(parent!!.childProjects.get("graaljs")!!.sourceSets.test.get().output) testImplementation(libs.log4j.core) testImplementation(project(":testutils")) + testImplementation(project(":addOns:graaljs")) + testImplementation(project(":addOns:scripts")) } diff --git a/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/scripts/ExtensionOpenApiScripts.java b/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/scripts/ExtensionOpenApiScripts.java new file mode 100644 index 00000000000..3f74da75f81 --- /dev/null +++ b/addOns/openapi/src/main/java/org/zaproxy/zap/extension/openapi/scripts/ExtensionOpenApiScripts.java @@ -0,0 +1,160 @@ +/* + * 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.zap.extension.openapi.scripts; + +import java.io.File; +import java.nio.file.Paths; +import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.parosproxy.paros.Constant; +import org.parosproxy.paros.extension.Extension; +import org.parosproxy.paros.extension.ExtensionAdaptor; +import org.zaproxy.zap.extension.ascan.ExtensionActiveScan; +import org.zaproxy.zap.extension.script.ExtensionScript; +import org.zaproxy.zap.extension.script.ScriptEngineWrapper; +import org.zaproxy.zap.extension.script.ScriptType; +import org.zaproxy.zap.extension.script.ScriptWrapper; + +public class ExtensionOpenApiScripts extends ExtensionAdaptor { + + private static final List> DEPENDENCIES = + List.of(ExtensionActiveScan.class, ExtensionScript.class); + private static final Logger LOGGER = LogManager.getLogger(ExtensionOpenApiScripts.class); + private static final String SCRIPT_SWAGGER_SECRET_DETECTOR = "SwaggerSecretDetector.js"; + + private ExtensionScript extScript; + + @Override + public String getName() { + return ExtensionOpenApiScripts.class.getSimpleName(); + } + + @Override + public String getUIName() { + return Constant.messages.getString("openapi.scripts.name"); + } + + @Override + public String getDescription() { + return Constant.messages.getString("openapi.scripts.desc"); + } + + @Override + public List> getDependencies() { + return DEPENDENCIES; + } + + @Override + public void postInit() { + extScript = + org.parosproxy.paros.control.Control.getSingleton() + .getExtensionLoader() + .getExtension(ExtensionScript.class); + addScripts(); + } + + @Override + public boolean canUnload() { + return true; + } + + @Override + public void unload() { + removeScripts(); + } + + private void addScripts() { + addScript( + SCRIPT_SWAGGER_SECRET_DETECTOR, + Constant.messages.getString("openapi.scripts.swaggerSecretDetector.desc"), + extScript.getScriptType(ExtensionActiveScan.SCRIPT_TYPE_ACTIVE), + false); + } + + private void addScript(String name, String description, ScriptType type, boolean isTemplate) { + try { + if (extScript.getScript(name) != null) { + return; + } + ScriptEngineWrapper engine = extScript.getEngineWrapper("Graal.js"); + if (engine == null) { + return; + } + + File file; + if (isTemplate) { + file = + Paths.get( + Constant.getZapHome(), + ExtensionScript.TEMPLATES_DIR, + type.getName(), + name) + .toFile(); + } else { + file = + Paths.get( + Constant.getZapHome(), + ExtensionScript.SCRIPTS_DIR, + ExtensionScript.SCRIPTS_DIR, + type.getName(), + name) + .toFile(); + } + ScriptWrapper script = new ScriptWrapper(name, description, engine, type, true, file); + extScript.loadScript(script); + if (isTemplate) { + extScript.addTemplate(script, false); + } else { + extScript.addScript(script, false); + } + } catch (Exception e) { + LOGGER.warn( + Constant.messages.getString( + "openapi.scripts.warn.couldNotAddScripts", e.getLocalizedMessage())); + } + } + + private void removeScripts() { + if (extScript == null) { + return; + } + removeScript(SCRIPT_SWAGGER_SECRET_DETECTOR, false); + } + + private void removeScript(String name, boolean isTemplate) { + ScriptWrapper script; + if (isTemplate) { + script = extScript.getTreeModel().getTemplate(name); + } else { + script = extScript.getScript(name); + } + + if (script == null) { + return; + } + + if (isTemplate) { + extScript.removeTemplate(script); + } else { + extScript.removeScript(script); + } + } +} diff --git a/addOns/openapi/src/main/javahelp/org/zaproxy/zap/extension/openapi/resources/help/contents/openapi.html b/addOns/openapi/src/main/javahelp/org/zaproxy/zap/extension/openapi/resources/help/contents/openapi.html index 9dce8c0354c..289aaf28300 100644 --- a/addOns/openapi/src/main/javahelp/org/zaproxy/zap/extension/openapi/resources/help/contents/openapi.html +++ b/addOns/openapi/src/main/javahelp/org/zaproxy/zap/extension/openapi/resources/help/contents/openapi.html @@ -113,5 +113,17 @@

Statistics

  • openapi.urls.added : The total number of URLs added when importing OpenAPI definitions
  • +

    Scripts

    +The following scripts are included with the add-on: + +

    Swagger Secret & Vulnerability Detector

    +This is an active script scan rule. It attempts to find exposed OpenAPI documentation that leaks sensitive secrets such +as API keys, OAuth client secrets, access tokens, or that runs vulnerable versions of Swagger UI. +

    + Latest code: SwaggerSecretDetector.js +
    + Alert ID: 100043. + + diff --git a/addOns/openapi/src/main/resources/org/zaproxy/zap/extension/openapi/resources/Messages.properties b/addOns/openapi/src/main/resources/org/zaproxy/zap/extension/openapi/resources/Messages.properties index 5494cb98621..21a8b454e07 100644 --- a/addOns/openapi/src/main/resources/org/zaproxy/zap/extension/openapi/resources/Messages.properties +++ b/addOns/openapi/src/main/resources/org/zaproxy/zap/extension/openapi/resources/Messages.properties @@ -59,6 +59,11 @@ openapi.parse.warn = Parsed OpenAPI definition with warnings - \nsee Output tab openapi.progress.importpane.currentimport = Importing: {0} +openapi.scripts.desc = Adds scripts relevant to OpenAPI specifications. +openapi.scripts.name = OpenAPI Scripts +openapi.scripts.swaggerSecretDetector.desc = This script attempts to find exposed OpenAPI documentation endpoints that may contain sensitive information. +openapi.scripts.warn.couldNotAddScripts = Could not add OpenAPI scripts: {0}. + openapi.spider.desc = OpenAPI Spider Integration openapi.spider.name = OpenAPI Spider diff --git a/addOns/openapi/src/main/zapHomeFiles/scripts/scripts/active/SwaggerSecretDetector.js b/addOns/openapi/src/main/zapHomeFiles/scripts/scripts/active/SwaggerSecretDetector.js new file mode 100644 index 00000000000..0ee3ba82a6a --- /dev/null +++ b/addOns/openapi/src/main/zapHomeFiles/scripts/scripts/active/SwaggerSecretDetector.js @@ -0,0 +1,380 @@ +// Note that new active scripts will initially be disabled +// ------------------------------------------------------------------- +// Swagger Secrets & Version Detector - ZAP Active Scan Rule Script +// ------------------------------------------------------------------- +const URI = Java.type("org.apache.commons.httpclient.URI"); +const ScanRuleMetadata = Java.type( + "org.zaproxy.addon.commonlib.scanrules.ScanRuleMetadata", +); +const CommonAlertTag = Java.type("org.zaproxy.addon.commonlib.CommonAlertTag"); +const SCAN_RULE_ID = "100043"; + +function getMetadata() { + return ScanRuleMetadata.fromYaml(` +id: ${SCAN_RULE_ID} +name: Swagger UI Secret & Vulnerability Detector +description: > + Detects exposed Swagger UI and OpenAPI endpoints that leak sensitive secrets such as API keys, + OAuth client secrets, access tokens, or run vulnerable versions. This scanner performs comprehensive + detection of sensitive information disclosure in API documentation. +solution: > + Remove hardcoded secrets from API documentation, restrict access to API documentation endpoints, + and upgrade Swagger UI to a secure version. Ensure proper authentication is required to access documentation. +category: info_gather +risk: high +confidence: medium +cweId: 522 # Insufficiently Protected Credentials +alertTags: + ${CommonAlertTag.OWASP_2021_A05_SEC_MISCONFIG.getTag()}: ${CommonAlertTag.OWASP_2021_A05_SEC_MISCONFIG.getValue()} + ${CommonAlertTag.OWASP_2017_A06_SEC_MISCONFIG.getTag()}: ${CommonAlertTag.OWASP_2017_A06_SEC_MISCONFIG.getValue()} +alertRefOverrides: + ${SCAN_RULE_ID}-1: + name: Vulnerable Swagger UI Version Detected + description: | + This Swagger UI version is known to contain vulnerabilities. Exploitation may allow unauthorized access, XSS, or token theft. + + Affected versions: + - Swagger UI v2 < 2.2.10 + - Swagger UI v3 < 3.24.3 + solution: Upgrade to the latest version of Swagger UI. Regularly review and patch known issues. + ${SCAN_RULE_ID}-2: + name: Exposed Secrets in Swagger/OpenAPI Path + description: > + Swagger UI endpoint exposes sensitive secrets such as client secrets, API keys, or OAuth tokens. + These secrets may be accessible in the HTML source and should not be exposed publicly, as this can lead to compromise. + solution: Remove hardcoded secrets from documentation and ensure the endpoint is protected with authentication. + references: + - https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/ +status: alpha +codeLink: https://github.com/zaproxy/community-scripts/blob/main/active/swagger-secret-detector.js +helpLink: https://www.zaproxy.org/docs/desktop/addons/openapi-support/#id-100043 +`); +} + +// ------------------------------------------------------------------- +// 1. List of commonly exposed Swagger/OpenAPI documentation paths +// ------------------------------------------------------------------- +const SWAGGER_PATHS = [ + // Low Attack Strength + "/swagger-ui/", + "/v3/api-docs", + "/swagger.json", + "/openapi.json", + "/api-docs", + "/docs/", + // Medium Attack Strength + "/swagger", + "/v2/api-docs", + "/swagger-ui/index.html", + "/openapi.yaml", + "/swagger.yaml", + "/swagger/ui/index.html", + // High Attack Strength + "/swagger/", + "/swagger/index.html", + "/swagger/ui", + "/swagger/ui/", + "/swagger/ui/index", + "/swagger-ui", + "/swagger-ui/index", + "/docs", +]; + +// ------------------------------------------------------------------- +// 2. Regex matchers for path filtering (more flexible than exact matches) +// ------------------------------------------------------------------- +const SWAGGER_REGEX_PATHS = [ + /\/swagger\/?$/i, + /\/swagger\/index\.html$/i, + /\/swagger\/ui\/?$/i, + /\/swagger\/ui\/index(\.html)?$/i, + /\/swagger-ui\/?$/i, + /\/swagger-ui\/index(\.html)?$/i, + /\/docs\/?$/i, + /\/api-docs$/i, + /\/v2\/api-docs$/i, + /\/v3\/api-docs$/i, + /\/swagger\.(json|yaml)$/i, + /\/openapi\.(json|yaml)$/i, + /\/api(\/v[0-9]+)?\/.*$/i, + /\/v[0-9]+\/swagger.*$/i, + /\/v[0-9]+\/openapi.*$/i, + /\/nswag\/?$/i, + /\/redoc\/?$/i, + /\/admin\/?$/i, + /\/config(\.json|\.yaml|\.yml|\.php)?$/i, + /\/debug(\.log|\.txt)?$/i, + /\/\.env$/i, + /\/\.git\/config$/i, + /\/login\/?$/i, + /\/signin\/?$/i, + /\/upload\/.*$/i, + /\/graphql$/i, + /\/graphiql$/i, + /\/phpinfo\.php$/i, + /\/server-status$/i, + /\/actuator\/.*$/i, + /\/\.git\/HEAD$/i, + /\/backup\.zip$/i, + /\/db\.sql$/i, +]; + +// ------------------------------------------------------------------- +// 3. Regex patterns to detect likely secrets in Swagger responses +// ------------------------------------------------------------------- +const SECRET_REGEXES = [ + /["']?clientId["']?\s*:\s*["'](?!client_id|""|.{0,6}$).*?["']/gi, + /["']?clientSecret["']?\s*:\s*["'](?!client_secret|""|.{0,6}$).*?["']/gi, + /["']?oAuth2ClientId["']?\s*:\s*["'](?!client_id|""|.{0,6}$).*?["']/gi, + /["']?oAuth2ClientSecret["']?\s*:\s*["'](?!client_secret|""|.{0,6}$).*?["']/gi, + /["']?api_key["']?\s*:\s*["'](?!your_api_key_here|""|.{0,6}$).*?["']/gi, + /["']?access_token["']?\s*:\s*["'](?!""|.{0,6}$).*?["']/gi, + /["']?authorization["']?\s*:\s*["']Bearer\s+(?!""|.{0,6}$).*?["']/gi, +]; + +// ------------------------------------------------------------------- +// 4. Known dummy/test values that should be ignored +// ------------------------------------------------------------------- +const FALSE_POSITIVES = [ + "clientid", + "clientsecret", + "string", + "n/a", + "null", + "na", + "true", + "false", + "value_here", + "your_key", + "your_api_key_here", + "demo_token", + "test1234", + "dummysecret", + "{token}", + "bearer{token}", + "placeholder", + "insert_value", +]; + +// ------------------------------------------------------------------- +// 5. False positive filter: heuristic to skip known dummy/test data +// ------------------------------------------------------------------- +function isFalsePositiveKV(kvString) { + if (!kvString || kvString.length < 1) return true; + + const kvMatch = kvString.match(/["']?([^"']+)["']?\s*:\s*["']?([^"']+)["']?/); + if (!kvMatch || kvMatch.length < 3) return false; + + const key = kvMatch[1].toLowerCase().trim(); + let value = kvMatch[2].toLowerCase().trim(); + value = value.replace(/[\s"'{}]/g, ""); + + if (value.length < 8) return true; + + const contextKeys = ["example", "description", "title", "note"]; + for (let i = 0; i < contextKeys.length; i++) { + if (key.indexOf(contextKeys[i]) !== -1) return true; + } + + const junkTokens = [ + "test", + "sample", + "dummy", + "mock", + "try", + "placeholder", + "your", + "insert", + ]; + for (let i = 0; i < junkTokens.length; i++) { + if ( + value.indexOf(junkTokens[i]) !== -1 || + key.indexOf(junkTokens[i]) !== -1 + ) + return true; + } + + for (let i = 0; i < FALSE_POSITIVES.length; i++) { + if (value === FALSE_POSITIVES[i]) return true; + } + + return false; +} + +// ------------------------------------------------------------------- +// 6. Redact secret values in evidence (show only first 5 chars) +// ------------------------------------------------------------------- +function redactSecret(secret) { + const parts = secret.split(":"); + if (parts.length < 2) return secret; + const value = parts.slice(1).join(":").trim().replace(/^"|"$/g, ""); + return parts[0] + ': "' + value.substring(0, 5) + '..."'; +} + +// ------------------------------------------------------------------- +// 7. Detect Swagger UI version in HTML/JS +// ------------------------------------------------------------------- +function detectSwaggerVersion(body) { + if (body.indexOf("SwaggerUIBundle") !== -1) return 3; + if ( + body.indexOf("SwaggerUi") !== -1 || + body.indexOf("window.swaggerUi") !== -1 || + body.indexOf("swashbuckleConfig") !== -1 + ) + return 2; + if (body.indexOf("NSwag") !== -1 || body.indexOf("nswagui") !== -1) return 4; + return 0; +} + +function extractVersion(body) { + const versionRegex = /version\s*[:=]\s*["']?(\d+\.\d+\.\d+)["']?/i; + const match = body.match(versionRegex); + return match ? match[1] : null; +} + +function versionToInt(v) { + const parts = v.split("."); + return ( + parseInt(parts[0], 10) * 10000 + + parseInt(parts[1], 10) * 100 + + parseInt(parts[2], 10) + ); +} + +// ------------------------------------------------------------------- +// 8. Main scan logic: runs once per host +// ------------------------------------------------------------------- +function scanHost(as, msg) { + const origUri = msg.getRequestHeader().getURI(); + const scheme = origUri.getScheme(); + const host = origUri.getHost(); + const port = origUri.getPort(); + const base = + scheme + + "://" + + host + + (port !== -1 && port !== 80 && port !== 443 ? ":" + port : ""); + + const pathsCount = + as.getAttackStrength() == "LOW" + ? 6 + : as.getAttackStrength() == "MEDIUM" + ? 12 + : SWAGGER_PATHS.length; + + // --- Check static Swagger paths --- + for (let i = 0; i < pathsCount; i++) { + if (as.isStop()) return; + scanPath( + as, + msg, + scheme, + host, + port, + SWAGGER_PATHS[i], + base + SWAGGER_PATHS[i], + ); + } +} + +// ------------------------------------------------------------------- +// 8. Main scan logic: runs once per node +// ------------------------------------------------------------------- +function scanNode(as, msg) { + // --- Check current request path if it matches any regex --- + const origUri = msg.getRequestHeader().getURI(); + const currentPath = origUri.getPath(); + const scheme = origUri.getScheme(); + const host = origUri.getHost(); + const port = origUri.getPort(); + const base = + scheme + + "://" + + host + + (port !== -1 && port !== 80 && port !== 443 ? ":" + port : ""); + + for (let r = 0; r < SWAGGER_REGEX_PATHS.length; r++) { + if (as.isStop()) return; + if (SWAGGER_REGEX_PATHS[r].test(currentPath)) { + scanPath(as, msg, scheme, host, port, currentPath, base + currentPath) + return; + } + } +} + +// ------------------------------------------------------------------- +// 9. Scan a single path (version + secret detection reused) +// ------------------------------------------------------------------- +function scanPath(as, origMsg, scheme, host, port, pathOnly, fullPath) { + const requestMsg = origMsg.cloneRequest(); + try { + requestMsg.getRequestHeader().setMethod("GET"); + const newUri = new URI(scheme, null, host, port, pathOnly); + requestMsg.getRequestHeader().setURI(newUri); + requestMsg.getRequestHeader().setContentLength(0); + + const origHeaders = origMsg.getRequestHeader(); + ["User-Agent", "Cookie", "Authorization"].forEach(function (header) { + const val = origHeaders.getHeader(header); + if (val) requestMsg.getRequestHeader().setHeader(header, val); + }); + + as.sendAndReceive(requestMsg, false, false); + } catch (err) { + return; + } + + const body = requestMsg.getResponseBody().toString(); + const version = detectSwaggerVersion(body); + const semver = extractVersion(body); + + if (semver && (version === 2 || version === 3)) { + const vInt = versionToInt(semver); + if ((version === 2 && vInt < 20210) || (version === 3 && vInt < 32403)) { + const cveReference = + version === 2 + ? "https://nvd.nist.gov/vuln/detail/CVE-2019-17495" + : "https://github.com/swagger-api/swagger-ui/releases/tag/v3.24.3"; + + as.newAlert("100043-1") + .setName("Vulnerable Swagger UI Version Detected (v" + semver + ")") + .setOtherInfo("Discovered at: " + fullPath) + .setReference(cveReference) + .setMessage(requestMsg) + .raise(); + } + } + + detectSecrets(as, requestMsg, fullPath, body); +} + +function detectSecrets(as, requestMsg, fullPath, body) { + const matches = {}; + for (let j = 0; j < SECRET_REGEXES.length; j++) { + const found = body.match(SECRET_REGEXES[j]); + if (found) { + for (let f = 0; f < found.length; f++) { + const match = found[f]; + if (!isFalsePositiveKV(match)) { + matches[match] = true; + } + } + } + } + + const evidenceRaw = Object.keys(matches); + const redactedEvidence = evidenceRaw.map(redactSecret); + // var evidenceString = redactedEvidence.length > 0 ? redactedEvidence[0] : null; + const foundClientId = evidenceRaw.some((e) => /clientId/i.test(e)); + const foundSecret = evidenceRaw.some((e) => + /clientSecret|api_key|access_token|authorization/i.test(e), + ); + + if (foundClientId && foundSecret) { + as.newAlert("100043-2") + .setEvidence(redactedEvidence[0]) + .setOtherInfo("All secrets exposed:\n" + redactedEvidence.join("\n")) + .setMessage(requestMsg) + .raise(); + } +} diff --git a/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/scripts/SwaggerSecretDetectorScriptUnitTest.java b/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/scripts/SwaggerSecretDetectorScriptUnitTest.java new file mode 100644 index 00000000000..22f966eb8bb --- /dev/null +++ b/addOns/openapi/src/test/java/org/zaproxy/zap/extension/openapi/scripts/SwaggerSecretDetectorScriptUnitTest.java @@ -0,0 +1,285 @@ +/* + * 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.zap.extension.openapi.scripts; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; + +import fi.iki.elonen.NanoHTTPD; +import fi.iki.elonen.NanoHTTPD.Response; +import java.nio.file.Path; +import java.util.stream.Stream; +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.parosproxy.paros.core.scanner.Alert; +import org.parosproxy.paros.core.scanner.Category; +import org.parosproxy.paros.core.scanner.Plugin; +import org.parosproxy.paros.network.HttpMessage; +import org.zaproxy.addon.commonlib.CommonAlertTag; +import org.zaproxy.zap.control.AddOn; +import org.zaproxy.zap.extension.graaljs.GraalJsActiveScriptScanRuleTestUtils; +import org.zaproxy.zap.testutils.NanoServerHandler; + +public class SwaggerSecretDetectorScriptUnitTest extends GraalJsActiveScriptScanRuleTestUtils { + @Override + public Path getScriptPath() throws Exception { + return Path.of( + getClass().getResource("/scripts/scripts/active/SwaggerSecretDetector.js").toURI()); + } + + @Test + void shouldReturnExpectedMappings() { + MatcherAssert.assertThat(rule.getId(), is(equalTo(100043))); + MatcherAssert.assertThat( + rule.getName(), is(equalTo("Swagger UI Secret & Vulnerability Detector"))); + MatcherAssert.assertThat(rule.getCategory(), is(equalTo(Category.INFO_GATHER))); + MatcherAssert.assertThat(rule.getRisk(), is(equalTo(Alert.RISK_HIGH))); + MatcherAssert.assertThat(rule.getCweId(), is(equalTo(522))); + MatcherAssert.assertThat(rule.getWascId(), is(equalTo(0))); + MatcherAssert.assertThat( + rule.getAlertTags().keySet(), + containsInAnyOrder( + CommonAlertTag.OWASP_2021_A05_SEC_MISCONFIG.getTag(), + CommonAlertTag.OWASP_2017_A06_SEC_MISCONFIG.getTag())); + MatcherAssert.assertThat(rule.getStatus(), is(equalTo(AddOn.Status.alpha))); + } + + @ParameterizedTest + @ValueSource( + strings = { + "SwaggerUIBundle version: 3.20.0", + "SwaggerUIBundle version: \"3.18.0\"", + "SwaggerUIBundle version = '3.10.1'", + "SwaggerUIBundle version = 3.0.0", + "SwaggerUi version: \"2.1.9\"", + "window.swaggerUi version = '2.2.5'", + "swashbuckleConfig version = 2.0.0" + }) + void shouldAlertForVulnerableVersionBodies(String body) throws Exception { + // Given + nano.addHandler(new StaticHandler("/swagger-ui/", body)); + HttpMessage msg = getHttpMessage("/foo/bar"); + rule.setAttackStrength(Plugin.AttackStrength.INSANE); + rule.init(msg, parent); + // When + rule.scan(); + // Then + assertThat(alertsRaised, hasSize(1)); + Alert alert = alertsRaised.get(0); + assertThat(alert.getAlertRef(), is(equalTo("100043-1"))); + assertThat(alert.getName(), startsWith("Vulnerable Swagger UI Version Detected")); + } + + @ParameterizedTest + @ValueSource( + strings = { + "SwaggerUIBundle version: 3.24.3", + "SwaggerUi version: 2.2.10", + "NSwag version: 4.0.0", + "nswagui version: 4.0.0", + "zaproxy version: 2.16.0" + }) + void shouldNotAlertForNonVulnerableVersionBodies(String body) throws Exception { + // Given + nano.addHandler(new StaticHandler("/swagger-ui/", body)); + HttpMessage msg = getHttpMessage("/foo/bar"); + rule.init(msg, parent); + // When + rule.scan(); + // Then + assertThat(alertsRaised, is(empty())); + } + + @ParameterizedTest + @ValueSource( + strings = { + "clientId:'abcdefgh' clientSecret: 'abcdefgh'", + "oAuth2ClientId: 'abcdefgh' api_key: \"abcdefgh\"", + "clientId:'abcdefgh' oAuth2ClientSecret: 'abcdefgh'", + "clientId:'abcdefgh' api_key: 'abcdefgh'", + "clientId:\"abcdefgh\" access_token: 'abcdefgh'", + "clientId:'abcdefgh' authorization: 'Bearer abcdefgh'", + }) + void shouldAlertForSecretsInBodies(String body) throws Exception { + // Given + nano.addHandler(new StaticHandler("/swagger-ui/", body)); + HttpMessage msg = getHttpMessage("/foo/bar"); + rule.init(msg, parent); + // When + rule.scan(); + // Then + assertThat(alertsRaised, hasSize(1)); + Alert alert = alertsRaised.get(0); + assertThat(alert.getAlertRef(), is(equalTo("100043-2"))); + assertThat(alert.getName(), is(equalTo("Exposed Secrets in Swagger/OpenAPI Path"))); + } + + @Test + void shouldCheckAllStaticPaths() throws Exception { + // Given + HttpMessage msg = getHttpMessage("/foo/bar"); + rule.init(msg, parent); + rule.setAttackStrength(Plugin.AttackStrength.INSANE); + // When + rule.scan(); + // Then + assertThat(alertsRaised, is(empty())); + MatcherAssert.assertThat(nano.getRequestedUris(), hasSize(20)); + MatcherAssert.assertThat( + nano.getRequestedUris(), + containsInAnyOrder( + "/swagger", + "/swagger/", + "/swagger/index.html", + "/swagger/ui", + "/swagger/ui/", + "/swagger/ui/index", + "/swagger/ui/index.html", + "/swagger-ui", + "/swagger-ui/", + "/swagger-ui/index.html", + "/swagger-ui/index", + "/docs", + "/docs/", + "/api-docs", + "/v2/api-docs", + "/v3/api-docs", + "/swagger.json", + "/swagger.yaml", + "/openapi.json", + "/openapi.yaml")); + } + + static Stream swaggerRegexSamplePaths() { + return Stream.of( + "/foo/swagger", + "/foo/swagger/index.html", + "/foo/swagger/ui", + "/foo/swagger/ui/index.html", + "/foo/swagger-ui", + "/foo/swagger-ui/index", + "/foo/docs", + "/foo/api-docs", + "/foo/v2/api-docs", + "/foo/v3/api-docs", + "/foo/swagger.json", + "/foo/openapi.yaml", + "/foo/api/v1/something", + "/foo/v1/swagger-ui", + "/foo/v1/openapi.json", + "/foo/nswag", + "/foo/redoc", + "/foo/admin", + "/foo/config.json", + "/foo/debug.log", + "/foo/.env", + "/foo/.git/config", + "/foo/login", + "/foo/signin", + "/foo/upload/file.txt", + "/foo/graphql", + "/foo/graphiql", + "/foo/phpinfo.php", + "/foo/server-status", + "/foo/actuator/health", + "/foo/.git/HEAD", + "/foo/backup.zip", + "/foo/db.sql"); + } + + @ParameterizedTest + @MethodSource("swaggerRegexSamplePaths") + void shouldRaiseAlertsForRegexPath(String path) throws Exception { + // Given + String body = + "clientId: \"abcd12345client\"\nclientSecret: \"secret98765value\"\nSwaggerUIBundle version: 3.20.0"; + nano.addHandler(new StaticHandler(path, body)); + HttpMessage msg = getHttpMessage(path); + rule.init(msg, parent); + // When + rule.scan(); + // Then + assertThat(alertsRaised, hasSize(2)); + MatcherAssert.assertThat(alertsRaised.get(0).getAlertRef(), is(equalTo("100043-1"))); + MatcherAssert.assertThat(alertsRaised.get(1).getAlertRef(), is(equalTo("100043-2"))); + } + + @Test + void shouldNotRaiseAlertOnNonSwaggerPath() throws Exception { + // Given + String path = "/notswagger"; + nano.addHandler(new StaticHandler(path, "No swagger here")); + HttpMessage msg = getHttpMessage(path); + rule.init(msg, parent); + // When + rule.scan(); + // Then + assertThat(alertsRaised, is(empty())); + } + + @Test + void shouldIgnoreFalsePositiveSecrets() throws Exception { + // Given + String path = "/swagger-ui/index.html"; + String body = + """ + + + clientId: "clientid" + clientSecret: "dummysecret" + + """; + nano.addHandler(new StaticHandler(path, body)); + HttpMessage msg = getHttpMessage(path); + rule.init(msg, parent); + // When + rule.scan(); + // Then + assertThat(alertsRaised, is(empty())); + } + + private static class StaticHandler extends NanoServerHandler { + private final String path; + private final String body; + + StaticHandler(String path, String body) { + super(path); + this.path = path; + this.body = body; + } + + @Override + protected Response serve(NanoHTTPD.IHTTPSession session) { + return path.equals(session.getUri()) + ? NanoHTTPD.newFixedLengthResponse( + Response.Status.OK, NanoHTTPD.MIME_HTML, body) + : NanoHTTPD.newFixedLengthResponse( + Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, ""); + } + } +}