From 05b6138ae948761286e141c06fca01870b03d3c2 Mon Sep 17 00:00:00 2001 From: Thim Date: Thu, 22 Jan 2026 08:44:44 +0100 Subject: [PATCH 1/7] fix Fuzzing: handle multiple request body examples --- src/application/Fuzzer.test.ts | 29 ++++ src/application/Fuzzer.ts | 130 +++++++++++------- .../__snapshots__/Fuzzer.test.ts.snap | 6 + src/utils/getRequestBodyExample.test.ts | 26 ++++ src/utils/getRequestBodyExample.ts | 45 ++++-- 5 files changed, 175 insertions(+), 61 deletions(-) create mode 100644 src/utils/getRequestBodyExample.test.ts diff --git a/src/application/Fuzzer.test.ts b/src/application/Fuzzer.test.ts index 9c2504a5..65809ace 100644 --- a/src/application/Fuzzer.test.ts +++ b/src/application/Fuzzer.test.ts @@ -100,6 +100,35 @@ describe('Fuzzer', () => { expect(result.item.request?.body?.raw).toMatchSnapshot() }) + it('should fuzz required fields using matching request body examples', async () => { + const fuzzItems = { + fuzzType: PortmanFuzzTypes.requestBody, + requiredFields: ['name'], + minimumNumberFields: [], + maximumNumberFields: [], + minLengthFields: [], + maxLengthFields: [] + } as FuzzingSchemaItems + + const requestBodyExamples = [ + { device_id: 'a15e3ff0-fb5b-4026-a7d4-a65aa02bbfb8' }, + { name: 'Ada Lovelace', provider: 'apple' } + ] + + fuzzer.injectFuzzRequiredVariation( + pmOpBody, + oaOpBody, + variationTest, + variationMeta, + fuzzItems, + requestBodyExamples + ) + + expect(fuzzer.fuzzVariations).toHaveLength(1) + const result = fuzzer.fuzzVariations[0] + expect(result.item.request?.body?.raw).toMatchSnapshot() + }) + it('should fuzz the 2nd required props of the request body', async () => { const fuzzItems = { fuzzType: PortmanFuzzTypes.requestBody, diff --git a/src/application/Fuzzer.ts b/src/application/Fuzzer.ts index 8d0a60c9..293dce41 100644 --- a/src/application/Fuzzer.ts +++ b/src/application/Fuzzer.ts @@ -18,7 +18,7 @@ import { import traverse from 'neotraverse/legacy' import { TestSuite, VariationWriter } from './' import { OpenAPIV3 } from 'openapi-types' -import { getByPath, getJsonContentType } from '../utils' +import { getByPath, getJsonContentType, getRequestBodyExamples } from '../utils' import { QueryParam } from 'postman-collection' import { PostmanDynamicVarGenerator } from '../services/PostmanDynamicVarGenerator' import { changeCase } from 'openapi-format' @@ -63,6 +63,7 @@ export class Fuzzer { // Analyse JSON schema const schema = reqBody?.content?.[jsonContentType]?.schema as OpenAPIV3.SchemaObject const fuzzItems = this.analyzeFuzzJsonSchema(schema) + const requestBodyExamples = getRequestBodyExamples(reqBody, jsonContentType) const fuzzReqBodySet = fuzzingSet.filter(fuzz => fuzz?.requestBody) as fuzzingConfig[] @@ -76,7 +77,8 @@ export class Fuzzer { oaOperation, variation, variationMeta, - fuzzItems + fuzzItems, + requestBodyExamples ) } @@ -284,7 +286,8 @@ export class Fuzzer { oaOperation: OasMappedOperation | null, variation: VariationConfig, variationMeta: VariationTestConfig | IntegrationTest | null, - fuzzItems: FuzzingSchemaItems | null + fuzzItems: FuzzingSchemaItems | null, + requestBodyExamples?: unknown[] ): void { // Early exit if no required fields defined const requiredFields = fuzzItems?.requiredFields || [] @@ -293,56 +296,91 @@ export class Fuzzer { const clonedVariation = JSON.parse(JSON.stringify(variation)) requiredFields.map(requiredField => { - // Set Pm request name - const variationFuzzName = `${pmOperation.item.name}[${variation.name}][required ${requiredField}]` - - // Clone postman operation as new variation operation - const operationVariation = pmOperation.clone({ - newId: changeCase(variationFuzzName, 'camelCase'), - name: variationFuzzName - }) + const filteredExamples = + fuzzItems?.fuzzType === PortmanFuzzTypes.requestBody && requestBodyExamples?.length + ? this.filterRequestBodyExamples(requestBodyExamples, requiredField) + : [] + const fallbackExamples = + requestBodyExamples && requestBodyExamples.length > 0 ? [requestBodyExamples[0]] : [] + const examplePayloads = filteredExamples.length > 0 ? filteredExamples : fallbackExamples + const examplesToUse = examplePayloads.length > 0 ? examplePayloads : [undefined] + const includeExampleSuffix = examplePayloads.length > 1 + + examplesToUse.forEach((examplePayload, exampleIndex) => { + const exampleSuffix = includeExampleSuffix ? ` [example ${exampleIndex + 1}]` : '' + // Set Pm request name + const variationFuzzName = `${pmOperation.item.name}[${variation.name}][required ${requiredField}]${exampleSuffix}` + + // Clone postman operation as new variation operation + const operationVariation = pmOperation.clone({ + newId: changeCase(variationFuzzName, 'camelCase'), + name: variationFuzzName + }) - // Set/Update Portman operation test type - this.testSuite.registerOperationTestType( - operationVariation, - PortmanTestTypes.variation, - false - ) + // Set/Update Portman operation test type + this.testSuite.registerOperationTestType( + operationVariation, + PortmanTestTypes.variation, + false + ) - // Remove requiredField from Postman operation - const newVariation = JSON.parse(JSON.stringify(clonedVariation)) - if (!newVariation?.overwrites) newVariation.overwrites = [] + // Remove requiredField from Postman operation + const newVariation = JSON.parse(JSON.stringify(clonedVariation)) + if (!newVariation?.overwrites) newVariation.overwrites = [] + + if (fuzzItems?.fuzzType === PortmanFuzzTypes.requestBody) { + if (examplePayload !== undefined) { + const exampleOverwrite = { + key: '.', + value: examplePayload, + overwrite: true + } as OverwriteRequestBodyConfig + this.addOverwriteRequestBody(newVariation, exampleOverwrite) + } + const fuzzRequestBody = { + key: requiredField, + remove: true + } as OverwriteRequestBodyConfig + this.addOverwriteRequestBody(newVariation, fuzzRequestBody) + } - if (fuzzItems?.fuzzType === PortmanFuzzTypes.requestBody) { - const fuzzRequestBody = { key: requiredField, remove: true } as OverwriteRequestBodyConfig - this.addOverwriteRequestBody(newVariation, fuzzRequestBody) - } + if (fuzzItems?.fuzzType === PortmanFuzzTypes.requestQueryParam) { + const fuzzRequestQueryParam = { + key: requiredField, + remove: true + } as OverwriteQueryParamConfig + this.addOverwriteRequestQueryParam(newVariation, fuzzRequestQueryParam) + } - if (fuzzItems?.fuzzType === PortmanFuzzTypes.requestQueryParam) { - const fuzzRequestQueryParam = { - key: requiredField, - remove: true - } as OverwriteQueryParamConfig - this.addOverwriteRequestQueryParam(newVariation, fuzzRequestQueryParam) - } + if (fuzzItems?.fuzzType === PortmanFuzzTypes.requestHeader) { + const fuzzRequestHeader = { + key: requiredField, + remove: true + } as OverwriteRequestHeadersConfig + this.addOverwriteRequestHeader(newVariation, fuzzRequestHeader) + } - if (fuzzItems?.fuzzType === PortmanFuzzTypes.requestHeader) { - const fuzzRequestHeader = { - key: requiredField, - remove: true - } as OverwriteRequestHeadersConfig - this.addOverwriteRequestHeader(newVariation, fuzzRequestHeader) - } + this.variationWriter.injectVariations( + operationVariation, + oaOperation, + newVariation, + variationMeta + ) - this.variationWriter.injectVariations( - operationVariation, - oaOperation, - newVariation, - variationMeta - ) + // Build up list of Fuzz Variations + this.fuzzVariations.push(operationVariation) + }) + }) + } - // Build up list of Fuzz Variations - this.fuzzVariations.push(operationVariation) + private filterRequestBodyExamples(examples: unknown[], requiredField: string): unknown[] { + return examples.filter(example => { + if (example === null || typeof example !== 'object') return false + const safeExample = JSON.parse(JSON.stringify(example)) + return getByPath( + safeExample as Record | Record[], + requiredField + ) !== undefined }) } diff --git a/src/application/__snapshots__/Fuzzer.test.ts.snap b/src/application/__snapshots__/Fuzzer.test.ts.snap index c439eb81..76913993 100644 --- a/src/application/__snapshots__/Fuzzer.test.ts.snap +++ b/src/application/__snapshots__/Fuzzer.test.ts.snap @@ -2979,6 +2979,12 @@ exports[`Fuzzer should fuzz the required prop of the request body 1`] = ` }" `; +exports[`Fuzzer should fuzz required fields using matching request body examples 1`] = ` +"{ + \\"provider\\": \\"apple\\" +}" +`; + exports[`Fuzzer should fuzz the required prop of the request header 1`] = ` Array [ Object { diff --git a/src/utils/getRequestBodyExample.test.ts b/src/utils/getRequestBodyExample.test.ts new file mode 100644 index 00000000..205e1af5 --- /dev/null +++ b/src/utils/getRequestBodyExample.test.ts @@ -0,0 +1,26 @@ +import { getRequestBodyExample, getRequestBodyExamples } from './getRequestBodyExample' + +describe('getRequestBodyExample helpers', () => { + it('collects and normalizes request body examples', () => { + const reqBody = { + content: { + 'application/json': { + example: '{"foo":"bar"}', + examples: { + first: { value: { foo: 'baz' } }, + second: { value: '{"foo":"qux"}' } + }, + schema: { example: { foo: 'last' } } + } + } + } + + expect(getRequestBodyExamples(reqBody, 'application/json')).toEqual([ + { foo: 'bar' }, + { foo: 'baz' }, + { foo: 'qux' }, + { foo: 'last' } + ]) + expect(getRequestBodyExample(reqBody, 'application/json')).toBe('{\n "foo": "bar"\n}') + }) +}) diff --git a/src/utils/getRequestBodyExample.ts b/src/utils/getRequestBodyExample.ts index 1b650e23..b5588725 100644 --- a/src/utils/getRequestBodyExample.ts +++ b/src/utils/getRequestBodyExample.ts @@ -1,26 +1,41 @@ +const normalizeRequestBodyExample = (example: unknown, contentType: string): unknown => { + if (!contentType.includes('json') || typeof example !== 'string') return example + try { + return JSON.parse(example) + } catch (error) { + return example + } +} + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export const getRequestBodyExample = (reqBody: any, contentType: string): string | undefined => { - if (!reqBody?.content) return undefined +export const getRequestBodyExamples = (reqBody: any, contentType: string): unknown[] => { + if (!reqBody?.content) return [] const content = reqBody.content[contentType] - if (!content) return undefined + if (!content) return [] + const examples = [] as unknown[] if (content.example !== undefined) { - return typeof content.example === 'string' - ? content.example - : JSON.stringify(content.example, null, 2) + examples.push(normalizeRequestBodyExample(content.example, contentType)) } if (content.examples) { - const exKey = Object.keys(content.examples)[0] - const exampleObj = content.examples[exKey] - const val = exampleObj?.value - if (val !== undefined) { - return typeof val === 'string' ? val : JSON.stringify(val, null, 2) - } + const exampleMap = content.examples as Record + Object.values(exampleMap).forEach(exampleObj => { + if (exampleObj?.value !== undefined) { + examples.push(normalizeRequestBodyExample(exampleObj.value, contentType)) + } + }) } if (content.schema && (content.schema as any).example !== undefined) { - const val = (content.schema as any).example - return typeof val === 'string' ? val : JSON.stringify(val, null, 2) + examples.push(normalizeRequestBodyExample((content.schema as any).example, contentType)) } - return undefined + return examples +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const getRequestBodyExample = (reqBody: any, contentType: string): string | undefined => { + const examples = getRequestBodyExamples(reqBody, contentType) + if (examples.length === 0) return undefined + const example = examples[0] + return typeof example === 'string' ? example : JSON.stringify(example, null, 2) } export const getRawLanguageFromContentType = (contentType: string): string => { From a9f9b83e0b15dabcefb5b6b4a0d0965bc247b597 Mon Sep 17 00:00:00 2001 From: Thim Date: Thu, 22 Jan 2026 09:02:24 +0100 Subject: [PATCH 2/7] tests: Extra fuzz testing --- src/application/Fuzzer.test.ts | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/application/Fuzzer.test.ts b/src/application/Fuzzer.test.ts index 65809ace..06944e73 100644 --- a/src/application/Fuzzer.test.ts +++ b/src/application/Fuzzer.test.ts @@ -129,6 +129,46 @@ describe('Fuzzer', () => { expect(result.item.request?.body?.raw).toMatchSnapshot() }) + it('should fuzz required fields for array examples with nested paths', async () => { + const fuzzItems = { + fuzzType: PortmanFuzzTypes.requestBody, + requiredFields: ['[0].to.device_token'], + minimumNumberFields: [], + maximumNumberFields: [], + minLengthFields: [], + maxLengthFields: [] + } as FuzzingSchemaItems + + const requestBodyExamples = [ + [ + { + reference: 'ref-123', + to: { device_id: 'a15e3ff0-fb5b-4026-a7d4-a65aa02bbfb8' } + } + ], + [ + { + reference: 'ref-123', + to: { device_token: 'ed2576bfb93a2e7abc26', provider: 'apple' } + } + ] + ] + + fuzzer.injectFuzzRequiredVariation( + pmOpBody, + oaOpBody, + variationTest, + variationMeta, + fuzzItems, + requestBodyExamples + ) + + expect(fuzzer.fuzzVariations).toHaveLength(1) + const result = fuzzer.fuzzVariations[0] + const rawBody = result.item.request?.body?.raw as string + expect(JSON.parse(rawBody)).toEqual([{ reference: 'ref-123', to: { provider: 'apple' } }]) + }) + it('should fuzz the 2nd required props of the request body', async () => { const fuzzItems = { fuzzType: PortmanFuzzTypes.requestBody, From c5b17d0ce3cf64d2746ca667ccde6f07c6b85e9c Mon Sep 17 00:00:00 2001 From: Thim Date: Thu, 22 Jan 2026 09:02:31 +0100 Subject: [PATCH 3/7] fix Fuzzing: handle multiple request body examples --- src/application/Fuzzer.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/application/Fuzzer.ts b/src/application/Fuzzer.ts index 293dce41..0627511e 100644 --- a/src/application/Fuzzer.ts +++ b/src/application/Fuzzer.ts @@ -330,9 +330,10 @@ export class Fuzzer { if (fuzzItems?.fuzzType === PortmanFuzzTypes.requestBody) { if (examplePayload !== undefined) { + const safeExamplePayload = JSON.parse(JSON.stringify(examplePayload)) const exampleOverwrite = { key: '.', - value: examplePayload, + value: safeExamplePayload, overwrite: true } as OverwriteRequestBodyConfig this.addOverwriteRequestBody(newVariation, exampleOverwrite) @@ -374,14 +375,18 @@ export class Fuzzer { } private filterRequestBodyExamples(examples: unknown[], requiredField: string): unknown[] { - return examples.filter(example => { - if (example === null || typeof example !== 'object') return false - const safeExample = JSON.parse(JSON.stringify(example)) - return getByPath( - safeExample as Record | Record[], - requiredField - ) !== undefined - }) + return examples + .filter(example => { + if (example === null || typeof example !== 'object') return false + const safeExample = JSON.parse(JSON.stringify(example)) + return ( + getByPath( + safeExample as Record | Record[], + requiredField + ) !== undefined + ) + }) + .map(example => JSON.parse(JSON.stringify(example))) } public injectFuzzMinimumVariation( From 9fb68832c86700875ffbed92b75692ed535da17a Mon Sep 17 00:00:00 2001 From: Thim Date: Thu, 22 Jan 2026 09:03:31 +0100 Subject: [PATCH 4/7] Fuzzing - Handle multiple request body examples --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20382bbf..8c5b2874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [Unreleased] +- Fuzzing - Handle multiple request body examples (#464) +- Bumped dependencies + ## v1.33.0 - (2025-09-12) - CLI - Exits with code 1 when Portman config validation fails (#705) From 6f1565f8a81ba37335a8ee5b01611b1fecb8c7e9 Mon Sep 17 00:00:00 2001 From: Thim Date: Thu, 22 Jan 2026 09:14:09 +0100 Subject: [PATCH 5/7] Updated readme --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index b7f829cb..82cf93ad 100644 --- a/README.md +++ b/README.md @@ -618,9 +618,7 @@ For more details, review the [Overwrites example](https://github.com/apideck-lib
-### Portman - `fuzzing` properties - BETA 🏗 - -NOTICE: This feature is considered BETA, since we are investigating additional fuzzing capabilities. +### Portman - `fuzzing` properties > Fuzzing or fuzz testing is an automated software testing technique that involves providing invalid, unexpected, or random data as inputs to a computer program (a REST API in the case of Portman). From 103097c6f4452e9880b059fea597de4448743f66 Mon Sep 17 00:00:00 2001 From: Thim Date: Thu, 5 Feb 2026 08:22:34 +0100 Subject: [PATCH 6/7] Fuzzing: Add support for required field contexts and improve request body example matching logic --- package-lock.json | 4 +- src/application/Fuzzer.test.ts | 146 ++++++++++ src/application/Fuzzer.ts | 219 ++++++++++++--- .../__snapshots__/Fuzzer.test.ts.snap | 250 ++++++++++++++---- src/types/PortmanConfig.ts | 17 ++ 5 files changed, 548 insertions(+), 88 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11cdca23..59cf1020 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6814,6 +6814,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -6833,7 +6834,8 @@ "node_modules/openapi-to-postmanv2/node_modules/async": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", - "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "license": "MIT" }, "node_modules/openapi-to-postmanv2/node_modules/commander": { "version": "2.20.3", diff --git a/src/application/Fuzzer.test.ts b/src/application/Fuzzer.test.ts index 06944e73..b97354b5 100644 --- a/src/application/Fuzzer.test.ts +++ b/src/application/Fuzzer.test.ts @@ -169,6 +169,148 @@ describe('Fuzzer', () => { expect(JSON.parse(rawBody)).toEqual([{ reference: 'ref-123', to: { provider: 'apple' } }]) }) + it('should select matching examples for anyOf without discriminator', async () => { + const schema: OpenAPIV3.SchemaObject = { + type: 'object', + anyOf: [ + { + type: 'object', + required: ['a'], + properties: { + a: { type: 'string' }, + common: { type: 'string' } + } + }, + { + type: 'object', + required: ['b'], + properties: { + b: { type: 'string' }, + common: { type: 'string' } + } + } + ] + } + + const fuzzItems = fuzzer.analyzeFuzzJsonSchema(schema) as FuzzingSchemaItems + const requestBodyExamples = [ + { a: 'first', common: 'alpha' }, + { b: 'second', common: 'beta' } + ] + + fuzzer.injectFuzzRequiredVariation( + pmOpBody, + oaOpBody, + variationTest, + variationMeta, + fuzzItems, + requestBodyExamples + ) + + expect(fuzzer.fuzzVariations).toHaveLength(2) + + const requiredA = fuzzer.fuzzVariations.find(variation => + variation.item.name.includes('[required a]') + ) + const requiredB = fuzzer.fuzzVariations.find(variation => + variation.item.name.includes('[required b]') + ) + + expect(requiredA).toBeDefined() + expect(requiredB).toBeDefined() + + const bodyA = JSON.parse(requiredA.item.request?.body?.raw as string) + const bodyB = JSON.parse(requiredB.item.request?.body?.raw as string) + + expect(bodyA).toEqual({ common: 'alpha' }) + expect(bodyB).toEqual({ common: 'beta' }) + }) + + it('should select matching examples for anyOf with discriminator', async () => { + const schema: OpenAPIV3.SchemaObject = { + type: 'object', + anyOf: [ + { + type: 'object', + required: ['type', 'name'], + properties: { + type: { enum: ['person'] }, + name: { type: 'string' } + } + }, + { + type: 'object', + required: ['type', 'company'], + properties: { + type: { enum: ['company'] }, + company: { type: 'string' } + } + } + ], + discriminator: { + propertyName: 'type' + } + } + + const fuzzItems = fuzzer.analyzeFuzzJsonSchema(schema) as FuzzingSchemaItems + const requestBodyExamples = [ + { type: 'person', name: 'Ada' }, + { type: 'company', company: 'ACME' } + ] + + fuzzer.injectFuzzRequiredVariation( + pmOpBody, + oaOpBody, + variationTest, + variationMeta, + fuzzItems, + requestBodyExamples + ) + + const requiredName = fuzzer.fuzzVariations.find(variation => + variation.item.name.includes('[required name]') + ) + const requiredCompany = fuzzer.fuzzVariations.find(variation => + variation.item.name.includes('[required company]') + ) + + expect(requiredName).toBeDefined() + expect(requiredCompany).toBeDefined() + + const bodyName = JSON.parse(requiredName.item.request?.body?.raw as string) + const bodyCompany = JSON.parse(requiredCompany.item.request?.body?.raw as string) + + expect(bodyName).toEqual({ type: 'person' }) + expect(bodyCompany).toEqual({ type: 'company' }) + }) + + it('should skip fuzzing when no matching request body example exists', async () => { + const schema: OpenAPIV3.SchemaObject = { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' } + } + } + + const fuzzItems = fuzzer.analyzeFuzzJsonSchema(schema) as FuzzingSchemaItems + const requestBodyExamples = [{ device_id: 'a15e3ff0-fb5b-4026-a7d4-a65aa02bbfb8' }] + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + + fuzzer.injectFuzzRequiredVariation( + pmOpBody, + oaOpBody, + variationTest, + variationMeta, + fuzzItems, + requestBodyExamples + ) + + expect(fuzzer.fuzzVariations).toHaveLength(0) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + it('should fuzz the 2nd required props of the request body', async () => { const fuzzItems = { fuzzType: PortmanFuzzTypes.requestBody, @@ -1109,6 +1251,10 @@ describe('Fuzzer', () => { const expected = { fuzzType: 'requestBody', requiredFields: ['container', 'container.value'], + requiredFieldContexts: [ + { path: 'container', branchPath: undefined }, + { path: 'container.value', branchPath: undefined } + ], minimumNumberFields: [ { path: 'container.value', diff --git a/src/application/Fuzzer.ts b/src/application/Fuzzer.ts index 0627511e..3c89dc60 100644 --- a/src/application/Fuzzer.ts +++ b/src/application/Fuzzer.ts @@ -2,6 +2,8 @@ import { OasMappedOperation } from 'src/oas' import { PostmanMappedOperation } from '../postman' import { fuzzingConfig, + FuzzingRequiredFieldContext, + FuzzingSchemaBranchContext, FuzzingSchemaItems, fuzzRequestBody, fuzzRequestHeader, @@ -296,15 +298,33 @@ export class Fuzzer { const clonedVariation = JSON.parse(JSON.stringify(variation)) requiredFields.map(requiredField => { + const requiredFieldContexts = + fuzzItems?.requiredFieldContexts?.filter(ctx => ctx.path === requiredField) || [] + const hasExamples = !!requestBodyExamples && requestBodyExamples.length > 0 const filteredExamples = - fuzzItems?.fuzzType === PortmanFuzzTypes.requestBody && requestBodyExamples?.length - ? this.filterRequestBodyExamples(requestBodyExamples, requiredField) + fuzzItems?.fuzzType === PortmanFuzzTypes.requestBody && hasExamples + ? this.filterRequestBodyExamples( + requestBodyExamples, + requiredField, + requiredFieldContexts + ) : [] - const fallbackExamples = - requestBodyExamples && requestBodyExamples.length > 0 ? [requestBodyExamples[0]] : [] - const examplePayloads = filteredExamples.length > 0 ? filteredExamples : fallbackExamples - const examplesToUse = examplePayloads.length > 0 ? examplePayloads : [undefined] - const includeExampleSuffix = examplePayloads.length > 1 + const examplesToUse = + hasExamples && fuzzItems?.fuzzType === PortmanFuzzTypes.requestBody + ? filteredExamples + : [undefined] + const includeExampleSuffix = examplesToUse.length > 1 + + if ( + fuzzItems?.fuzzType === PortmanFuzzTypes.requestBody && + hasExamples && + examplesToUse.length === 0 + ) { + console.warn( + `[portman] No matching request body example found for required field "${requiredField}" in operation "${pmOperation.item.name}". Skipping fuzz variation.` + ) + return + } examplesToUse.forEach((examplePayload, exampleIndex) => { const exampleSuffix = includeExampleSuffix ? ` [example ${exampleIndex + 1}]` : '' @@ -374,21 +394,68 @@ export class Fuzzer { }) } - private filterRequestBodyExamples(examples: unknown[], requiredField: string): unknown[] { + private filterRequestBodyExamples( + examples: unknown[], + requiredField: string, + requiredFieldContexts: FuzzingRequiredFieldContext[] = [] + ): unknown[] { return examples .filter(example => { if (example === null || typeof example !== 'object') return false const safeExample = JSON.parse(JSON.stringify(example)) - return ( + const hasRequiredField = getByPath( safeExample as Record | Record[], requiredField ) !== undefined + if (!hasRequiredField) return false + + if (requiredFieldContexts.length === 0) return true + + return requiredFieldContexts.some(context => + this.matchesBranchContext(safeExample, context) ) }) .map(example => JSON.parse(JSON.stringify(example))) } + private matchesBranchContext( + example: Record | Record[], + requiredFieldContext: FuzzingRequiredFieldContext + ): boolean { + const branchPath = requiredFieldContext.branchPath || [] + if (branchPath.length === 0) return true + + return branchPath.every(branchContext => { + const exampleSegment = + branchContext.path === '' + ? example + : (getByPath( + example as Record | Record[], + branchContext.path + ) as Record | Record[] | undefined) + if (!exampleSegment || typeof exampleSegment !== 'object') return false + + const discriminator = branchContext.discriminator + if (discriminator?.value !== undefined) { + const discriminatorValue = (exampleSegment as Record)[ + discriminator.propertyName + ] + return discriminatorValue === discriminator.value + } + + const requiredProps = branchContext.requiredProps || [] + if (requiredProps.length === 0) return true + + return requiredProps.every(prop => { + return ( + getByPath(exampleSegment as Record | Record[], prop) !== + undefined + ) + }) + }) + } + public injectFuzzMinimumVariation( pmOperation: PostmanMappedOperation, oaOperation: OasMappedOperation | null, @@ -865,11 +932,15 @@ export class Fuzzer { if (!originalJsonSchema) return fuzzItems // Copy jsonSchema to keep the original jsonSchema untouched const jsonSchema = { ...originalJsonSchema } as OpenAPIV3.SchemaObject + const requiredFieldContexts = this.collectRequiredFieldsWithContext(jsonSchema) + fuzzItems.requiredFieldContexts = requiredFieldContexts + fuzzItems.requiredFields = Array.from( + new Set(requiredFieldContexts.map(context => context.path)) + ) const skipSchemaKeys = ['properties', 'items', 'allOf', 'anyOf', 'oneOf'] traverse(jsonSchema).forEach(function (node) { let path = `` - let requiredPath = `` const key = this.key as string // Merge anyOf, oneOf, allOf OpenAPI schema objects into a simplified schema object @@ -927,32 +998,9 @@ export class Fuzzer { if (item?.isRoot && item?.node?.type === 'array') { path += `[0].` } - - // Handle required path - requiredPath = path }) } - if (node?.required) { - // Build path for nested required properties - if (node?.type === 'object' && key && !skipSchemaKeys.includes(key)) { - requiredPath += `${key}.` - } - - // Register fuzz-able required fields from the 'required' element on an object; exclude properties named 'required'. - if (key !== 'properties' && Array.isArray(node.required)) { - const requiredFuzz = node.required.map(req => `${requiredPath}${req}`) - fuzzItems.requiredFields = fuzzItems.requiredFields.concat(requiredFuzz) || [] - } - } - - // Unregister fuzz-able nullable required fields - if (node?.nullable === true && fuzzItems.requiredFields.length > 0) { - fuzzItems.requiredFields = fuzzItems.requiredFields.filter( - item => item !== `${requiredPath}${key}` - ) - } - // Register all fuzz-able items, excluding properties that are named after reserved words. if (key !== 'properties') { let pathBase = path @@ -1000,6 +1048,111 @@ export class Fuzzer { return fuzzItems } + private collectRequiredFieldsWithContext( + schema: OpenAPIV3.SchemaObject | undefined, + basePath = '', + branchPath: FuzzingSchemaBranchContext[] = [] + ): FuzzingRequiredFieldContext[] { + if (!schema) return [] + + const results: FuzzingRequiredFieldContext[] = [] + const schemaAnyOf = (schema as OpenAPIV3.SchemaObject).anyOf + const schemaOneOf = (schema as OpenAPIV3.SchemaObject).oneOf + const modelType = schemaAnyOf ? 'anyOf' : schemaOneOf ? 'oneOf' : undefined + const branches = (schemaAnyOf || schemaOneOf) as OpenAPIV3.SchemaObject[] | undefined + + if (Array.isArray(branches) && modelType) { + branches.forEach((branchSchema, index) => { + const discriminator = schema.discriminator + const discriminatorValue = discriminator + ? this.getDiscriminatorValue(branchSchema, discriminator) + : undefined + const branchContext: FuzzingSchemaBranchContext = { + type: modelType, + index, + path: basePath, + discriminator: discriminator?.propertyName + ? { + propertyName: discriminator.propertyName, + value: discriminatorValue + } + : undefined, + requiredProps: Array.isArray(branchSchema.required) + ? branchSchema.required.map(req => `${req}`) + : [] + } + results.push( + ...this.collectRequiredFieldsWithContext( + branchSchema, + basePath, + branchPath.concat(branchContext) + ) + ) + }) + } + + if (schema.type === 'object' && schema.required && schema.properties) { + schema.required.forEach(requiredProp => { + const propSchema = schema.properties?.[requiredProp] as OpenAPIV3.SchemaObject | undefined + if (propSchema?.nullable === true) return + const path = this.appendPath(basePath, requiredProp) + results.push({ + path, + branchPath: branchPath.length > 0 ? [...branchPath] : undefined + }) + }) + } + + if (schema.type === 'object' && schema.properties) { + Object.entries(schema.properties).forEach(([propName, propSchema]) => { + results.push( + ...this.collectRequiredFieldsWithContext( + propSchema as OpenAPIV3.SchemaObject, + this.appendPath(basePath, propName), + branchPath + ) + ) + }) + } + + if (schema.type === 'array' && schema.items) { + results.push( + ...this.collectRequiredFieldsWithContext( + schema.items as OpenAPIV3.SchemaObject, + this.appendPath(basePath, '[0]'), + branchPath + ) + ) + } + + return results + } + + private appendPath(basePath: string, segment: string): string { + if (!basePath) return segment + if (segment.startsWith('[')) return `${basePath}${segment}` + return `${basePath}.${segment}` + } + + private getDiscriminatorValue( + branchSchema: OpenAPIV3.SchemaObject, + discriminator: OpenAPIV3.DiscriminatorObject + ): string | undefined { + const propSchema = (branchSchema as OpenAPIV3.SchemaObject).properties?.[ + discriminator.propertyName + ] as OpenAPIV3.SchemaObject | undefined + + if (propSchema && Array.isArray((propSchema as any).enum) && (propSchema as any).enum.length) { + return `${(propSchema as any).enum[0]}` + } + + if (propSchema && (propSchema as any).const !== undefined) { + return `${(propSchema as any).const}` + } + + return undefined + } + public analyzeQuerySchema( queryParam: OpenAPIV3.ParameterObject | undefined ): FuzzingSchemaItems | null { diff --git a/src/application/__snapshots__/Fuzzer.test.ts.snap b/src/application/__snapshots__/Fuzzer.test.ts.snap index 76913993..24d0217c 100644 --- a/src/application/__snapshots__/Fuzzer.test.ts.snap +++ b/src/application/__snapshots__/Fuzzer.test.ts.snap @@ -45,14 +45,8 @@ Object { "value": 1, }, ], - "requiredFields": Array [ - "name", - "websites[0].url", - "social_links[0].url", - "phone_numbers[0].number", - "emails[0].email", - "custom_fields[0].id", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -87,6 +81,16 @@ Object { "value": 0, }, ], + "requiredFieldContexts": Array [ + Object { + "branchPath": undefined, + "path": "numberField", + }, + Object { + "branchPath": undefined, + "path": "stringField", + }, + ], "requiredFields": Array [ "numberField", "stringField", @@ -145,11 +149,8 @@ Object { "value": 1, }, ], - "requiredFields": Array [ - "code", - "nestedArray", - "nestedArray[0].code2", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -204,9 +205,8 @@ Object { "value": 1, }, ], - "requiredFields": Array [ - "nestedArray", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -241,9 +241,8 @@ Object { "value": 1, }, ], - "requiredFields": Array [ - "code", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -278,6 +277,7 @@ Object { "value": 1, }, ], + "requiredFieldContexts": Array [], "requiredFields": Array [], } `; @@ -313,9 +313,8 @@ Object { "value": 1, }, ], - "requiredFields": Array [ - "nestedArray[0].level1[0].level2[0].code", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -350,6 +349,7 @@ Object { "value": 1, }, ], + "requiredFieldContexts": Array [], "requiredFields": Array [], } `; @@ -385,9 +385,8 @@ Object { "value": 1, }, ], - "requiredFields": Array [ - "nestedArray[0].items", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -422,6 +421,7 @@ Object { "value": 1, }, ], + "requiredFieldContexts": Array [], "requiredFields": Array [], } `; @@ -457,9 +457,8 @@ Object { "value": 1, }, ], - "requiredFields": Array [ - "nestedArray[0].nestedProperties", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -494,6 +493,7 @@ Object { "value": 1, }, ], + "requiredFieldContexts": Array [], "requiredFields": Array [], } `; @@ -529,9 +529,8 @@ Object { "value": 1, }, ], - "requiredFields": Array [ - "nestedOb.level1.level2.code", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -542,6 +541,7 @@ Object { "maximumNumberFields": Array [], "minLengthFields": Array [], "minimumNumberFields": Array [], + "requiredFieldContexts": Array [], "requiredFields": Array [], } `; @@ -577,6 +577,7 @@ Object { "value": 1, }, ], + "requiredFieldContexts": Array [], "requiredFields": Array [], } `; @@ -588,14 +589,8 @@ Object { "maximumNumberFields": Array [], "minLengthFields": Array [], "minimumNumberFields": Array [], - "requiredFields": Array [ - "nullable", - "required", - "maximum", - "minimum", - "maxLength", - "minLength", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -638,11 +633,8 @@ Object { }, ], "minimumNumberFields": Array [], - "requiredFields": Array [ - "name", - "array[0].id", - "array[0].scheme", - ], + "requiredFieldContexts": Array [], + "requiredFields": Array [], } `; @@ -685,8 +677,33 @@ Object { }, ], "minimumNumberFields": Array [], + "requiredFieldContexts": Array [ + Object { + "branchPath": Array [ + Object { + "discriminator": undefined, + "index": 0, + "path": "", + "requiredProps": Array [], + "type": "anyOf", + }, + ], + "path": "array[0].id", + }, + Object { + "branchPath": Array [ + Object { + "discriminator": undefined, + "index": 0, + "path": "", + "requiredProps": Array [], + "type": "anyOf", + }, + ], + "path": "array[0].scheme", + }, + ], "requiredFields": Array [ - "name", "array[0].id", "array[0].scheme", ], @@ -712,12 +729,24 @@ Object { "value": 1, }, ], + "requiredFieldContexts": Array [ + Object { + "branchPath": undefined, + "path": "payload", + }, + Object { + "branchPath": undefined, + "path": "payload.order", + }, + Object { + "branchPath": undefined, + "path": "payload.order.address", + }, + ], "requiredFields": Array [ "payload", "payload.order", "payload.order.address", - "payload.order.address.street", - "payload.order.address.cityName", ], } `; @@ -741,6 +770,50 @@ Object { "value": 1, }, ], + "requiredFieldContexts": Array [ + Object { + "branchPath": undefined, + "path": "payload", + }, + Object { + "branchPath": undefined, + "path": "payload.order", + }, + Object { + "branchPath": undefined, + "path": "payload.order.address", + }, + Object { + "branchPath": Array [ + Object { + "discriminator": undefined, + "index": 0, + "path": "payload.order.address", + "requiredProps": Array [ + "street", + "cityName", + ], + "type": "anyOf", + }, + ], + "path": "payload.order.address.street", + }, + Object { + "branchPath": Array [ + Object { + "discriminator": undefined, + "index": 0, + "path": "payload.order.address", + "requiredProps": Array [ + "street", + "cityName", + ], + "type": "anyOf", + }, + ], + "path": "payload.order.address.cityName", + }, + ], "requiredFields": Array [ "payload", "payload.order", @@ -770,6 +843,50 @@ Object { "value": 1, }, ], + "requiredFieldContexts": Array [ + Object { + "branchPath": undefined, + "path": "payload", + }, + Object { + "branchPath": undefined, + "path": "payload.order", + }, + Object { + "branchPath": undefined, + "path": "payload.order.address", + }, + Object { + "branchPath": Array [ + Object { + "discriminator": undefined, + "index": 0, + "path": "payload.order.address", + "requiredProps": Array [ + "street", + "cityName", + ], + "type": "oneOf", + }, + ], + "path": "payload.order.address.street", + }, + Object { + "branchPath": Array [ + Object { + "discriminator": undefined, + "index": 0, + "path": "payload.order.address", + "requiredProps": Array [ + "street", + "cityName", + ], + "type": "oneOf", + }, + ], + "path": "payload.order.address.cityName", + }, + ], "requiredFields": Array [ "payload", "payload.order", @@ -819,8 +936,33 @@ Object { }, ], "minimumNumberFields": Array [], + "requiredFieldContexts": Array [ + Object { + "branchPath": Array [ + Object { + "discriminator": undefined, + "index": 0, + "path": "", + "requiredProps": Array [], + "type": "oneOf", + }, + ], + "path": "array[0].id", + }, + Object { + "branchPath": Array [ + Object { + "discriminator": undefined, + "index": 0, + "path": "", + "requiredProps": Array [], + "type": "oneOf", + }, + ], + "path": "array[0].scheme", + }, + ], "requiredFields": Array [ - "name", "array[0].id", "array[0].scheme", ], @@ -963,6 +1105,12 @@ Object { } `; +exports[`Fuzzer should fuzz required fields using matching request body examples 1`] = ` +"{ + \\"provider\\": \\"apple\\" +}" +`; + exports[`Fuzzer should fuzz the 2nd required props of the request body 1`] = ` "{ \\"name\\": \\"Elon Musk\\", @@ -2979,12 +3127,6 @@ exports[`Fuzzer should fuzz the required prop of the request body 1`] = ` }" `; -exports[`Fuzzer should fuzz required fields using matching request body examples 1`] = ` -"{ - \\"provider\\": \\"apple\\" -}" -`; - exports[`Fuzzer should fuzz the required prop of the request header 1`] = ` Array [ Object { diff --git a/src/types/PortmanConfig.ts b/src/types/PortmanConfig.ts index a04cfe89..b0739986 100644 --- a/src/types/PortmanConfig.ts +++ b/src/types/PortmanConfig.ts @@ -303,12 +303,29 @@ export type PortmanFuzzType = (typeof PortmanFuzzTypes)[keyof typeof PortmanFuzz export type FuzzingSchemaItems = { fuzzType: PortmanFuzzType requiredFields: string[] + requiredFieldContexts?: FuzzingRequiredFieldContext[] minimumNumberFields?: fuzzingSchemaItem[] maximumNumberFields?: fuzzingSchemaItem[] minLengthFields?: fuzzingSchemaItem[] maxLengthFields?: fuzzingSchemaItem[] } +export type FuzzingRequiredFieldContext = { + path: string + branchPath?: FuzzingSchemaBranchContext[] +} + +export type FuzzingSchemaBranchContext = { + type: 'anyOf' | 'oneOf' + index: number + path: string + discriminator?: { + propertyName: string + value?: string + } + requiredProps?: string[] +} + type fuzzingOptions = { enabled: boolean } From 089e10cf6058001295261178c01e1883cc90327f Mon Sep 17 00:00:00 2001 From: Thim Date: Thu, 5 Feb 2026 23:59:21 +0100 Subject: [PATCH 7/7] Fuzzing - Handle multiple request body examples --- CHANGELOG.md | 1 + src/application/Fuzzer.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c5b2874..1ee11eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased] - Fuzzing - Handle multiple request body examples (#464) +- Fuzzing: Improved required field contexts and examples (#464) - Bumped dependencies ## v1.33.0 - (2025-09-12) diff --git a/src/application/Fuzzer.test.ts b/src/application/Fuzzer.test.ts index b97354b5..f6abc69f 100644 --- a/src/application/Fuzzer.test.ts +++ b/src/application/Fuzzer.test.ts @@ -295,7 +295,7 @@ describe('Fuzzer', () => { const fuzzItems = fuzzer.analyzeFuzzJsonSchema(schema) as FuzzingSchemaItems const requestBodyExamples = [{ device_id: 'a15e3ff0-fb5b-4026-a7d4-a65aa02bbfb8' }] - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) fuzzer.injectFuzzRequiredVariation( pmOpBody,