From 41fdfbb590a48ae70ed9b5ea5767960c3420cbce Mon Sep 17 00:00:00 2001 From: Viko Date: Thu, 20 Aug 2026 09:59:19 +0700 Subject: [PATCH 1/3] Capture the Java app at /scanner/dast instead of the deprecated bare /scanner (#122) The other two apps already pointed at /scanner/dast. The Java one could not, because that endpoint did not exist until SasanLabs/VulnerableApp#725 landed as PR #733. It exists now, so facade no longer has to call the path that answers with a Deprecation header. With only this commit applied the merged payload is unchanged: 153 entries under VulnerableApp before and after, and the whole response byte for byte identical. The next commit in this branch does change it, by 69 bytes, deliberately. --- nginx.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nginx.conf b/nginx.conf index 940f483..13d54a1 100755 --- a/nginx.conf +++ b/nginx.conf @@ -94,7 +94,7 @@ http { default_type 'application/json'; content_by_lua_block { local vulnerableAppResponse, vulnerableAppJspResponse, vulnerableAppPhpResponse = ngx.location.capture_multi({ - { "/VulnerableApp/scanner" }, + { "/VulnerableApp/scanner/dast" }, { "/VulnerableApp-jsp/scanner/dast" }, { "/VulnerableApp-php/scanner/dast" } }) From 450a48384e5ba7a63acabb5d0a818991d0701559 Mon Sep 17 00:00:00 2001 From: Viko Date: Thu, 20 Aug 2026 10:26:44 +0700 Subject: [PATCH 2/3] Skip a 200 response whose body is not JSON when merging (#122) VulnerableApp-php answers both scanner endpoints with HTTP 200, Content-Type text/html, and a body naming the path it was asked for, so the dast one reads "/VulnerableApp-php/scanner/dast is not available" and the sast one the same with sast. The merge only looked at the status, so that sentence was spliced into the aggregate as if it were a JSON value and the whole response stopped being JSON. Measured on the docker-compose.without_llm.yml stack: before the change /scanner/dast and /scanner/sast both failed to parse, after it they parse and carry 153 and 154 entries. /VulnerabilityDefinitions is byte for byte unchanged, since all three apps return JSON there. --- lua-modules/vulnerableapp_utility.lua | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lua-modules/vulnerableapp_utility.lua b/lua-modules/vulnerableapp_utility.lua index d3c5ee9..7d6c7f9 100755 --- a/lua-modules/vulnerableapp_utility.lua +++ b/lua-modules/vulnerableapp_utility.lua @@ -1,27 +1,39 @@ local vulnerableapp_utility = {} +-- An app can answer 200 with a body that is not JSON. VulnerableApp-php does exactly that on the +-- scanner endpoints, replying "/VulnerableApp-php/scanner/dast is not available" as text/html. +-- Splicing such a body in produces a document that is not JSON at all, so the whole merged +-- response becomes unreadable rather than one app being missing from it. +local function carries_json(response) + if (not response or response.status ~= 200 or not response.body) then + return false + end + local first = response.body:match("^%s*(.)") + return first == "[" or first == "{" +end + function vulnerableapp_utility.merge_vulnerability_information(vulnerableAppResponse, vulnerableAppJspResponse, vulnerableAppPhpResponse, llmForgeResponse) local response = "{" local appendComma = false - if (vulnerableAppResponse.status == 200) then + if (carries_json(vulnerableAppResponse)) then response = response .. '"VulnerableApp":' .. vulnerableAppResponse.body appendComma = true end - if (llmForgeResponse and llmForgeResponse.status == 200) then + if (carries_json(llmForgeResponse)) then if (appendComma) then response = response .. "," end appendComma = true response = response .. '"llmforge":' .. llmForgeResponse.body end - if (vulnerableAppJspResponse.status == 200) then + if (carries_json(vulnerableAppJspResponse)) then if (appendComma) then response = response .. "," end appendComma = true response = response .. '"VulnerableApp-jsp":' .. vulnerableAppJspResponse.body end - if (vulnerableAppPhpResponse.status == 200) then + if (carries_json(vulnerableAppPhpResponse)) then if (appendComma) then response = response .. "," end From 3a9ec409735e6c2e70dda9bde4da851ac507b8e1 Mon Sep 17 00:00:00 2001 From: Viko Date: Thu, 20 Aug 2026 14:58:40 +0700 Subject: [PATCH 3/3] Decode the body instead of reading its first character (#122) The previous commit accepted a body whose first non-space character was a brace or a bracket. That is enough to keep the php sentence out, but not enough in general: a body of {error} passes it, and so does one that starts as JSON and is cut off part way. Either would put the aggregate back in the state this PR set out to fix. carries_json now refuses a response the subrequest reported as truncated, and decodes the body with OpenResty's bundled cjson.safe, accepting it only when the result is an object or an array. The automated review on this PR raised both points. Two decisions worth stating. The decoded value is thrown away and the merge still splices the raw bytes, so each app's own key order and formatting reach the caller exactly as before. And a bare number or string is refused even though it is valid JSON, because the aggregate maps an app name to its findings; that also keeps the behaviour the first-character check already had. On the docker-compose.without_llm.yml stack the output does not move: /scanner/dast, /scanner/sast and /VulnerabilityDefinitions are byte for byte identical to the previous commit, and /VulnerabilityDefinitions is still byte for byte identical to main. Nine cases were then driven through merge_vulnerability_information directly, twice: a truncated flag, a truncated body, {error}, a plain sentence, a bare number, a 404 and an empty object all behave as described, and the merged document parses in every one of them, including the case where every app is refused and the result is {}. cjson.safe was checked in openresty/openresty:alpine, the image this Dockerfile builds on, and decoding the 26 KB, 153-entry scanner response there stays under 0.1 ms, measured at 0.06 to 0.08 ms across runs. luacheck reports 0 warnings and 0 errors. --- lua-modules/vulnerableapp_utility.lua | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lua-modules/vulnerableapp_utility.lua b/lua-modules/vulnerableapp_utility.lua index 7d6c7f9..7fb8676 100755 --- a/lua-modules/vulnerableapp_utility.lua +++ b/lua-modules/vulnerableapp_utility.lua @@ -1,15 +1,23 @@ +local cjson = require("cjson.safe") + local vulnerableapp_utility = {} -- An app can answer 200 with a body that is not JSON. VulnerableApp-php does exactly that on the -- scanner endpoints, replying "/VulnerableApp-php/scanner/dast is not available" as text/html. -- Splicing such a body in produces a document that is not JSON at all, so the whole merged -- response becomes unreadable rather than one app being missing from it. +-- +-- The body is decoded only to check it and is then discarded: the merge still splices the raw +-- bytes, so each app's own key order and formatting reach the caller unchanged. A truncated body +-- is refused because ngx.location.capture reports one with status 200 and a partial body, which +-- can begin like JSON and still end mid-document. Only an object or an array is accepted, since +-- the aggregate maps an app name to its findings and a bare number or string would satisfy the +-- syntax without fitting that shape. local function carries_json(response) - if (not response or response.status ~= 200 or not response.body) then + if (not response or response.status ~= 200 or not response.body or response.truncated) then return false end - local first = response.body:match("^%s*(.)") - return first == "[" or first == "{" + return type(cjson.decode(response.body)) == "table" end function vulnerableapp_utility.merge_vulnerability_information(vulnerableAppResponse, vulnerableAppJspResponse, vulnerableAppPhpResponse, llmForgeResponse)