|
| 1 | +// Note that new active scripts will initially be disabled |
| 2 | +// ------------------------------------------------------------------- |
| 3 | +// Swagger Secrets & Version Detector - ZAP Active Scan Rule Script |
| 4 | +// ------------------------------------------------------------------- |
| 5 | +// Modern ZAP registration using getMetadata() function |
| 6 | +// Import required ZAP Java types for modern registration |
| 7 | + |
| 8 | +var URI = Java.type('org.apache.commons.httpclient.URI'); |
| 9 | +var ScanRuleMetadata = Java.type("org.zaproxy.addon.commonlib.scanrules.ScanRuleMetadata"); |
| 10 | +var CommonAlertTag = Java.type("org.zaproxy.addon.commonlib.CommonAlertTag"); |
| 11 | +function getMetadata() { |
| 12 | +return ScanRuleMetadata.fromYaml(` |
| 13 | +id: 100001 |
| 14 | +name: Swagger UI Secret & Vulnerability Detector |
| 15 | +description: > |
| 16 | + Detects exposed Swagger UI and OpenAPI endpoints that leak sensitive secrets such as API keys, |
| 17 | + OAuth client secrets, access tokens, or run vulnerable versions. This scanner performs comprehensive |
| 18 | + detection of sensitive information disclosure in API documentation. |
| 19 | +solution: > |
| 20 | + Remove hardcoded secrets from API documentation, restrict access to API documentation endpoints, |
| 21 | + and upgrade Swagger UI to a secure version. Ensure proper authentication is required to access documentation. |
| 22 | +references: |
| 23 | + - https://swagger.io/docs/ |
| 24 | + - https://owasp.org/www-project-api-security/ |
| 25 | +category: info_gather |
| 26 | +risk: high |
| 27 | +confidence: medium |
| 28 | +cweId: 200 # CWE-200: Exposure of Sensitive Information to an Unauthorized Actor |
| 29 | +wascId: 13 # WASC-13: Information Leakage |
| 30 | +alertTags: |
| 31 | + ${CommonAlertTag.OWASP_2021_A05_SEC_MISCONFIG.getTag()}: ${CommonAlertTag.OWASP_2021_A05_SEC_MISCONFIG.getValue()} |
| 32 | + ${CommonAlertTag.OWASP_2017_A06_SEC_MISCONFIG.getTag()}: ${CommonAlertTag.OWASP_2017_A06_SEC_MISCONFIG.getValue()} |
| 33 | +status: alpha |
| 34 | +codeLink: https://example.com/swagger-ui-detector.js |
| 35 | +helpLink: https://www.example.com/ |
| 36 | +`); |
| 37 | +} |
| 38 | + |
| 39 | +// ------------------------------------------------------------------- |
| 40 | +// 1. List of commonly exposed Swagger/OpenAPI documentation paths |
| 41 | +// ------------------------------------------------------------------- |
| 42 | +var SWAGGER_PATHS = [ |
| 43 | + "/swagger", "/swagger/", "/swagger/index.html", "/swagger/ui", "/swagger/ui/", |
| 44 | + "/swagger/ui/index", "/swagger/ui/index.html", "/swagger-ui", "/swagger-ui/", |
| 45 | + "/swagger-ui/index.html", "/swagger-ui/index", "/docs", "/docs/", |
| 46 | + "/api-docs", "/v2/api-docs", "/v3/api-docs", "/swagger.json", |
| 47 | + "/swagger.yaml", "/openapi.json", "/openapi.yaml" |
| 48 | +]; |
| 49 | + |
| 50 | +// ------------------------------------------------------------------- |
| 51 | +// 2. Regex matchers for path filtering (more flexible than exact matches) |
| 52 | +// ------------------------------------------------------------------- |
| 53 | +var SWAGGER_REGEX_PATHS = [ |
| 54 | + /\/swagger\/?$/i, |
| 55 | + /\/swagger\/index\.html$/i, |
| 56 | + /\/swagger\/ui\/?$/i, |
| 57 | + /\/swagger\/ui\/index(\.html)?$/i, |
| 58 | + /\/swagger-ui\/?$/i, |
| 59 | + /\/swagger-ui\/index(\.html)?$/i, |
| 60 | + /\/docs\/?$/i, |
| 61 | + /\/api-docs$/i, |
| 62 | + /\/v2\/api-docs$/i, |
| 63 | + /\/v3\/api-docs$/i, |
| 64 | + /\/swagger\.(json|yaml)$/i, |
| 65 | + /\/openapi\.(json|yaml)$/i, |
| 66 | + /\/api(\/v[0-9]+)?\/.*$/i, |
| 67 | + /\/v[0-9]+\/swagger.*$/i, |
| 68 | + /\/v[0-9]+\/openapi.*$/i, |
| 69 | + /\/nswag\/?$/i, |
| 70 | + /\/redoc\/?$/i, |
| 71 | + /\/admin\/?$/i, |
| 72 | + /\/config(\.json|\.yaml|\.yml|\.php)?$/i, |
| 73 | + /\/debug(\.log|\.txt)?$/i, |
| 74 | + /\/\.env$/i, |
| 75 | + /\/\.git\/config$/i, |
| 76 | + /\/login\/?$/i, |
| 77 | + /\/signin\/?$/i, |
| 78 | + /\/upload\/.*$/i, |
| 79 | + /\/graphql$/i, |
| 80 | + /\/graphiql$/i, |
| 81 | + /\/phpinfo\.php$/i, |
| 82 | + /\/server-status$/i, |
| 83 | + /\/actuator\/.*$/i, |
| 84 | + /\/\.git\/HEAD$/i, |
| 85 | + /\/backup\.zip$/i, |
| 86 | + /\/db\.sql$/i |
| 87 | +]; |
| 88 | + |
| 89 | +// ------------------------------------------------------------------- |
| 90 | +// 3. Regex patterns to detect likely secrets in Swagger responses |
| 91 | +// ------------------------------------------------------------------- |
| 92 | +var SECRET_REGEXES = [ |
| 93 | + /["']?clientId["']?\s*:\s*["'](?!client_id|""|.{0,6}$).*?["']/gi, |
| 94 | + /["']?clientSecret["']?\s*:\s*["'](?!client_secret|""|.{0,6}$).*?["']/gi, |
| 95 | + /["']?oAuth2ClientId["']?\s*:\s*["'](?!client_id|""|.{0,6}$).*?["']/gi, |
| 96 | + /["']?oAuth2ClientSecret["']?\s*:\s*["'](?!client_secret|""|.{0,6}$).*?["']/gi, |
| 97 | + /["']?api_key["']?\s*:\s*["'](?!your_api_key_here|""|.{0,6}$).*?["']/gi, |
| 98 | + /["']?access_token["']?\s*:\s*["'](?!""|.{0,6}$).*?["']/gi, |
| 99 | + /["']?authorization["']?\s*:\s*["']Bearer\s+(?!""|.{0,6}$).*?["']/gi |
| 100 | +]; |
| 101 | + |
| 102 | +// ------------------------------------------------------------------- |
| 103 | +// 4. Known dummy/test values that should be ignored |
| 104 | +// ------------------------------------------------------------------- |
| 105 | +var FALSE_POSITIVES = [ |
| 106 | + "clientid", "clientsecret", "string", "n/a", "null", "na", "true", "false", |
| 107 | + "value_here", "your_key", "your_api_key_here", "demo_token", "test1234", |
| 108 | + "dummysecret", "{token}", "bearer{token}", "placeholder", "insert_value" |
| 109 | +]; |
| 110 | + |
| 111 | +// ------------------------------------------------------------------- |
| 112 | +// 5. False positive filter: heuristic to skip known dummy/test data |
| 113 | +// ------------------------------------------------------------------- |
| 114 | +function isFalsePositiveKV(kvString) { |
| 115 | + if (!kvString || kvString.length < 1) return true; |
| 116 | + |
| 117 | + var kvMatch = kvString.match(/["']?([^"']+)["']?\s*:\s*["']?([^"']+)["']?/); |
| 118 | + if (!kvMatch || kvMatch.length < 3) return false; |
| 119 | + |
| 120 | + var key = kvMatch[1].toLowerCase().trim(); |
| 121 | + var value = kvMatch[2].toLowerCase().trim(); |
| 122 | + value = value.replace(/[\s"'{}]/g, ''); |
| 123 | + |
| 124 | + if (value.length < 8) return true; |
| 125 | + |
| 126 | + var contextKeys = ["example", "description", "title", "note"]; |
| 127 | + for (var i = 0; i < contextKeys.length; i++) { |
| 128 | + if (key.indexOf(contextKeys[i]) !== -1) return true; |
| 129 | + } |
| 130 | + |
| 131 | + var junkTokens = ["test", "sample", "dummy", "mock", "try", "placeholder", "your", "insert"]; |
| 132 | + for (var i = 0; i < junkTokens.length; i++) { |
| 133 | + if (value.indexOf(junkTokens[i]) !== -1 || key.indexOf(junkTokens[i]) !== -1) return true; |
| 134 | + } |
| 135 | + |
| 136 | + for (var i = 0; i < FALSE_POSITIVES.length; i++) { |
| 137 | + if (value === FALSE_POSITIVES[i]) return true; |
| 138 | + } |
| 139 | + |
| 140 | + return false; |
| 141 | +} |
| 142 | + |
| 143 | +// ------------------------------------------------------------------- |
| 144 | +// 6. Redact secret values in evidence (show only first 5 chars) |
| 145 | +// ------------------------------------------------------------------- |
| 146 | +function redactSecret(secret) { |
| 147 | + var parts = secret.split(':'); |
| 148 | + if (parts.length < 2) return secret; |
| 149 | + var value = parts.slice(1).join(':').trim().replace(/^"|"$/g, ''); |
| 150 | + return parts[0] + ': "' + value.substring(0, 5) + '..."'; |
| 151 | +} |
| 152 | + |
| 153 | +// ------------------------------------------------------------------- |
| 154 | +// 7. Detect Swagger UI version in HTML/JS |
| 155 | +// ------------------------------------------------------------------- |
| 156 | +function detectSwaggerVersion(body) { |
| 157 | + if (body.indexOf('SwaggerUIBundle') !== -1) return 3; |
| 158 | + if (body.indexOf('SwaggerUi') !== -1 || body.indexOf('window.swaggerUi') !== -1 || body.indexOf('swashbuckleConfig') !== -1) return 2; |
| 159 | + if (body.indexOf('NSwag') !== -1 || body.indexOf('nswagui') !== -1) return 4; |
| 160 | + return 0; |
| 161 | +} |
| 162 | + |
| 163 | +function extractVersion(body) { |
| 164 | + var versionRegex = /version\s*[:=]\s*["']?(\d+\.\d+\.\d+)["']?/i; |
| 165 | + var match = body.match(versionRegex); |
| 166 | + return match ? match[1] : null; |
| 167 | +} |
| 168 | + |
| 169 | +function versionToInt(v) { |
| 170 | + var parts = v.split("."); |
| 171 | + return (parseInt(parts[0], 10) * 10000) + (parseInt(parts[1], 10) * 100) + parseInt(parts[2], 10); |
| 172 | +} |
| 173 | + |
| 174 | +// ------------------------------------------------------------------- |
| 175 | +// 8. Main scan logic: runs once per node |
| 176 | +// ------------------------------------------------------------------- |
| 177 | +function scanNode(as, msg) { |
| 178 | + var origUri = msg.getRequestHeader().getURI(); |
| 179 | + var scheme = origUri.getScheme(); |
| 180 | + var host = origUri.getHost(); |
| 181 | + var port = origUri.getPort(); |
| 182 | + var base = scheme + "://" + host + ((port !== -1 && port !== 80 && port !== 443) ? ":" + port : ""); |
| 183 | + |
| 184 | + // --- Pass 1: Check static Swagger paths --- |
| 185 | + for (var i = 0; i < SWAGGER_PATHS.length; i++) { |
| 186 | + scanPath(as, msg, scheme, host, port, SWAGGER_PATHS[i], base + SWAGGER_PATHS[i]); |
| 187 | + } |
| 188 | + |
| 189 | + // --- Pass 2: Check current request path if it matches any regex --- |
| 190 | + var currentPath = origUri.getPath(); |
| 191 | + for (var r = 0; r < SWAGGER_REGEX_PATHS.length; r++) { |
| 192 | + if (SWAGGER_REGEX_PATHS[r].test(currentPath)) { |
| 193 | + scanPath(as, msg, scheme, host, port, currentPath, base + currentPath); |
| 194 | + } |
| 195 | + } |
| 196 | +} |
| 197 | + |
| 198 | +// ------------------------------------------------------------------- |
| 199 | +// 9. Scan a single path (version + secret detection reused) |
| 200 | +// ------------------------------------------------------------------- |
| 201 | +function scanPath(as, origMsg, scheme, host, port, pathOnly, fullPath) { |
| 202 | + var requestMsg = origMsg.cloneRequest(); |
| 203 | + |
| 204 | + try { |
| 205 | + requestMsg.getRequestHeader().setMethod("GET"); |
| 206 | + var newUri = new URI(scheme, null, host, port, pathOnly); |
| 207 | + requestMsg.getRequestHeader().setURI(newUri); |
| 208 | + requestMsg.getRequestHeader().setContentLength(0); |
| 209 | + |
| 210 | + var origHeaders = origMsg.getRequestHeader(); |
| 211 | + ["User-Agent", "Cookie", "Authorization"].forEach(function (header) { |
| 212 | + var val = origHeaders.getHeader(header); |
| 213 | + if (val) requestMsg.getRequestHeader().setHeader(header, val); |
| 214 | + }); |
| 215 | + |
| 216 | + as.sendAndReceive(requestMsg, false, false); |
| 217 | + } catch (err) { |
| 218 | + return; |
| 219 | + } |
| 220 | + |
| 221 | + var body = requestMsg.getResponseBody().toString(); |
| 222 | + var version = detectSwaggerVersion(body); |
| 223 | + var semver = extractVersion(body); |
| 224 | + |
| 225 | + if (semver && (version === 2 || version === 3)) { |
| 226 | + var vInt = versionToInt(semver); |
| 227 | + if ((version === 2 && vInt < 20210) || (version === 3 && vInt < 32403)) { |
| 228 | + var cveReference = (version === 2) |
| 229 | + ? "https://nvd.nist.gov/vuln/detail/CVE-2019-17495" |
| 230 | + : "https://github.com/swagger-api/swagger-ui/releases/tag/v3.24.3"; |
| 231 | + |
| 232 | + as.newAlert() |
| 233 | + .setRisk(3) |
| 234 | + .setConfidence(2) |
| 235 | + .setName("Vulnerable Swagger UI version detected (v" + semver + ")") |
| 236 | + .setAlertRef("100001-1") |
| 237 | + .setDescription("This Swagger UI version is known to contain vulnerabilities. Exploitation may allow unauthorized access, XSS, or token theft.\n\nAffected versions:\n- Swagger UI v2 < 2.2.10\n- Swagger UI v3 < 3.24.3") |
| 238 | + .setOtherInfo("Discovered at: " + fullPath) |
| 239 | + .setSolution("Upgrade to the latest version of Swagger UI. Regularly review and patch known issues.") |
| 240 | + .setReference(cveReference) |
| 241 | + .setMessage(requestMsg) |
| 242 | + .raise(); |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + detectSecrets(as, requestMsg, fullPath, body); |
| 247 | +} |
| 248 | + |
| 249 | +function detectSecrets(as, requestMsg, fullPath, body) { |
| 250 | + var matches = {}; |
| 251 | + for (var j = 0; j < SECRET_REGEXES.length; j++) { |
| 252 | + var found = body.match(SECRET_REGEXES[j]); |
| 253 | + if (found) { |
| 254 | + for (var f = 0; f < found.length; f++) { |
| 255 | + var match = found[f]; |
| 256 | + if (!isFalsePositiveKV(match)) { |
| 257 | + matches[match] = true; |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + } |
| 262 | + |
| 263 | + var evidenceRaw = Object.keys(matches); |
| 264 | + var redactedEvidence = evidenceRaw.map(redactSecret); |
| 265 | + var foundClientId = evidenceRaw.some(e => /clientId/i.test(e)); |
| 266 | + var foundSecret = evidenceRaw.some(e => /clientSecret|api_key|access_token|authorization/i.test(e)); |
| 267 | + |
| 268 | + if (foundClientId && foundSecret) { |
| 269 | + as.newAlert() |
| 270 | + .setRisk(3) |
| 271 | + .setConfidence(2) |
| 272 | + .setName("Exposed secrets in Swagger/OpenAPI path") |
| 273 | + .setAlertRef("100001-2") |
| 274 | + .setDescription("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.") |
| 275 | + .setEvidence(redactedEvidence.join("\n")) |
| 276 | + .setOtherInfo("Discovered at: " + fullPath) |
| 277 | + .setSolution("Remove hardcoded secrets from documentation and ensure the endpoint is protected with authentication.") |
| 278 | + .setReference("https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/") |
| 279 | + .setMessage(requestMsg) |
| 280 | + .raise(); |
| 281 | + } |
| 282 | +} |
0 commit comments