diff --git a/src/application/environment/environmentBuildReport.test.ts b/src/application/environment/environmentBuildReport.test.ts index a9825c3193..905cb03a11 100644 --- a/src/application/environment/environmentBuildReport.test.ts +++ b/src/application/environment/environmentBuildReport.test.ts @@ -389,3 +389,52 @@ it.each([ skipped: NONE_SKIPPED, }); }); + +it.each([ + { text: "password=example", pointers: ["/password"] }, + { text: "token=a&page=1&token=b", pointers: ["/token", "/token"] }, + { text: "%70assword=encoded", pointers: ["/password"] }, + { text: "flag&password=example", pointers: ["/password"] }, +])( + "reviews retained form names without a recorded media type: $text", + ({ text, pointers }) => { + const capture = scenarioCaptureFixture( + [ + { + method: "POST", + url: "/api/session", + status: 200, + mediaType: "application/json", + body: "{}", + requestBody: text, + }, + ], + { request_body: true, response_body: true, header_values: true }, + ); + expect( + scanCredentialNames(scenarioCaptureRecords(capture, "review").records), + ).toEqual({ + count: pointers.length, + pointers: pointers.map((pointer) => `record 0 request ${pointer}`), + skipped: NONE_SKIPPED, + }); + }, +); + +it("keeps JSON interpretation before an untyped form fallback and honors explicit non-form types", () => { + const json = record(7, undefined, undefined); + const plain = record(8, undefined, undefined); + (json.reported as { request: { postData: unknown } }).request.postData = { + mimeType: "", + text: '{"name":"password=example","token":"actual"}', + }; + (plain.reported as { request: { postData: unknown } }).request.postData = { + mimeType: "text/plain", + text: "password=example", + }; + expect(scanCredentialNames([json, plain])).toEqual({ + count: 1, + pointers: ["record 7 request /token"], + skipped: NONE_SKIPPED, + }); +}); diff --git a/src/application/environment/environmentBuildReport.ts b/src/application/environment/environmentBuildReport.ts index 11707bd1ba..5214aac4dc 100644 --- a/src/application/environment/environmentBuildReport.ts +++ b/src/application/environment/environmentBuildReport.ts @@ -103,18 +103,21 @@ const parsedBody = ( if (typeof text !== "string") return undefined; if (cut || Buffer.byteLength(text, "utf8") > SCAN_BODY_LIMIT_BYTES) return { kind: "skipped" }; - const format = typeof mimeType === "string" ? mediaTypeEssence(mimeType) : ""; - if (format === "application/x-www-form-urlencoded") - return { kind: "form", fields: [...new URLSearchParams(text)] }; + const format = + typeof mimeType === "string" ? mediaTypeEssence(mimeType) : null; + const form = format === "application/x-www-form-urlencoded"; const start = text.trimStart()[0]; - if (start === "{" || start === "[") { + if (!form && (start === "{" || start === "[")) { try { return { kind: "json", value: JSON.parse(text) }; } catch { return undefined; } } - return undefined; + // An unclassified capture can still retain form text. Review its potential names without changing Evidence or manifest interpretation. + return form || (format === null && text.includes("=")) + ? { kind: "form", fields: [...new URLSearchParams(text)] } + : undefined; }; /** Name/value pairs such as HAR headers and query strings. */ diff --git a/src/domain/analysisView/environmentCoverageView.ts b/src/domain/analysisView/environmentCoverageView.ts index e2a836ec6f..4c5ca4b188 100644 --- a/src/domain/analysisView/environmentCoverageView.ts +++ b/src/domain/analysisView/environmentCoverageView.ts @@ -32,7 +32,8 @@ export const WITHHELD_WRITE_HINTS = { "The write's path names no collection or item the capture reads. Read the collection or item it changes.", "no-call": "No recorded call of this tool was modelled, for example because every call failed. Record a successful call.", - "body-unusable": "The request body is not a usable JSON object.", + "body-unusable": + "The retained request or response cannot be modelled. Record a successful call with a complete supported request and reproducible response.", "no-observation": "Nothing showed the entity or document this write changed. Read a created entity back by id right after the write; for an update or delete the state must already hold the entity, so read it before the write or record the create that made it.", "unexplained-change":