diff --git a/.changeset/thick-eyes-create.md b/.changeset/thick-eyes-create.md new file mode 100644 index 0000000000..6c308511d8 --- /dev/null +++ b/.changeset/thick-eyes-create.md @@ -0,0 +1,6 @@ +--- +"@redocly/respect-core": minor +"@redocly/cli": minor +--- + +Added the `no-secrets-masking` option to the respect command, allowing raw (unmasked) output to be generated. diff --git a/__tests__/respect/reveal-masked-input-secrets/__snapshots__/reveal-masked-input-secrets.test.ts.snap b/__tests__/respect/reveal-masked-input-secrets/__snapshots__/reveal-masked-input-secrets.test.ts.snap new file mode 100644 index 0000000000..2de9794cf1 --- /dev/null +++ b/__tests__/respect/reveal-masked-input-secrets/__snapshots__/reveal-masked-input-secrets.test.ts.snap @@ -0,0 +1,284 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should reveal masked input values 1`] = ` +"──────────────────────────────────────────────────────────────────────────────── + + Running workflow reveal-masked-input-secrets.arazzo.yaml / get-museum-hours + + ✓ GET /museum-hours - step get-museum-hours + +    Request URL: https://redocly.com/_mock/demo/openapi/museum-api/museum-hours +    Request Headers: +      accept: application/json, application/problem+json +      authorization: Basic Og== +      password: password +      username: John +      masked-combined-value: John and password and maybe Basic Og== +      multi-word-secret: Bearer password + + +    Response status code: 200 +    Response time: ms +    Response Headers: +    Response Body: +      [ +       { +       "date": "2023-09-11", +       "timeOpen": "09:00", +       "timeClose": "18:00" +       }, +       { +       "date": "2023-09-12", +       "timeOpen": "09:00", +       "timeClose": "18:00" +       }, +       { +       "date": "2023-09-13", +       "timeOpen": "09:00", +       "timeClose": "18:00" +       }, +       { +       "date": "2023-09-14", +       "timeOpen": "09:00", +       "timeClose": "18:00" +       }, +       { +       "date": "2023-09-15", +       "timeOpen": "10:00", +       "timeClose": "16:00" +       }, +       { +       "date": "2023-09-18", +       "timeOpen": "09:00", +       "timeClose": "18:00" +       }, +       { +       "date": "2023-09-19", +       "timeOpen": "09:00", +       "timeClose": "18:00" +       }, +       { +       "date": "2023-09-20", +       "timeOpen": "09:00", +       "timeClose": "18:00" +       }, +       { +       "date": "2023-09-21", +       "timeOpen": "09:00", +       "timeClose": "18:00" +       }, +       { +       "date": "2023-09-22", +       "timeOpen": "10:00", +       "timeClose": "16:00" +       } +      ] + +    ✓ success criteria check - $statusCode == 200 +    ✓ status code check - $statusCode in [200, 400, 404] +    ✓ content-type check +    ✓ schema check + +──────────────────────────────────────────────────────────────────────────────── + + Running workflow reveal-masked-input-secrets.arazzo.yaml / events-crud + + ✓ GET /special-events - step list-events + +    Request URL: https://redocly.com/_mock/demo/openapi/museum-api/special-events +    Request Headers: +      accept: application/json, application/problem+json +      authorization: Basic Og== +      multi-word-secret: composed Basic Og== + + +    Response status code: 200 +    Response time: ms +    Response Headers: +    Response Body: +      [ +       { +       "eventId": "f3e0e76e-e4a8-466e-ab9c-ae36c15b8e97", +       "name": "Sasquatch Ballet", +       "location": "Seattle... probably", +       "eventDescription": "They're big, they're hairy, but they're also graceful. Come learn how the biggest feet can have the lightest touch.", +       "dates": [ +       "2023-12-15", +       "2023-12-22" +       ], +       "price": 40 +       }, +       { +       "eventId": "2f14374a-9c65-4ee5-94b7-fba66d893483", +       "name": "Solar Telescope Demonstration", +       "location": "Far from the sun.", +       "eventDescription": "Look at the sun without going blind!", +       "dates": [ +       "2023-09-07", +       "2023-09-14" +       ], +       "price": 50 +       }, +       { +       "eventId": "6aaa61ba-b2aa-4868-b803-603dbbf7bfdb", +       "name": "Cook like a Caveman", +       "location": "Fire Pit on East side", +       "eventDescription": "Learn to cook on an open flame.", +       "dates": [ +       "2023-11-10", +       "2023-11-17", +       "2023-11-24" +       ], +       "price": 5 +       }, +       { +       "eventId": "602b75e1-5696-4ab8-8c7a-f9e13580f910", +       "name": "Underwater Basket Weaving", +       "location": "Rec Center Pool next door.", +       "eventDescription": "Learn to weave baskets underwater.", +       "dates": [ +       "2023-09-12", +       "2023-09-15" +       ], +       "price": 15 +       }, +       { +       "eventId": "dad4bce8-f5cb-4078-a211-995864315e39", +       "name": "Mermaid Treasure Identification and Analysis", +       "location": "Room Sea-12", +       "eventDescription": "Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits — kindly donated by Ariel.", +       "dates": [ +       "2023-09-05", +       "2023-09-08" +       ], +       "price": 30 +       }, +       { +       "eventId": "6744a0da-4121-49cd-8479-f8cc20526495", +       "name": "Time Traveler Tea Party", +       "location": "Temporal Tearoom", +       "eventDescription": "Sip tea with important historical figures.", +       "dates": [ +       "2023-11-18", +       "2023-11-25", +       "2023-12-02" +       ], +       "price": 60 +       }, +       { +       "eventId": "3be6453c-03eb-4357-ae5a-984a0e574a54", +       "name": "Pirate Coding Workshop", +       "location": "Computer Room", +       "eventDescription": "Captain Blackbeard shares his love of the C...language. And possibly Arrrrr (R lang).", +       "dates": [ +       "2023-10-29", +       "2023-10-30", +       "2023-10-31" +       ], +       "price": 45 +       }, +       { +       "eventId": "9d90d29a-2af5-4206-97d9-9ea9ceadcb78", +       "name": "Llama Street Art Through the Ages", +       "location": "Auditorium", +       "eventDescription": "Llama street art?! Alpaca my bags -- let's go!", +       "dates": [ +       "2023-10-29", +       "2023-10-30", +       "2023-10-31" +       ], +       "price": 45 +       }, +       { +       "eventId": "a3c7b2c4-b5fb-4ef7-9322-00a919864957", +       "name": "The Great Parrot Debate", +       "location": "Outdoor Amphitheatre", +       "eventDescription": "See leading parrot minds discuss important geopolitical issues.", +       "dates": [ +       "2023-11-03", +       "2023-11-10" +       ], +       "price": 35 +       }, +       { +       "eventId": "b92d46b7-4c5d-422b-87a5-287767e26f29", +       "name": "Eat a Bunch of Corn", +       "location": "Cafeteria", +       "eventDescription": "We accidentally bought too much corn. Please come eat it.", +       "dates": [ +       "2023-11-10", +       "2023-11-17", +       "2023-11-24" +       ], +       "price": 5 +       } +      ] + +    ✓ status code check - $statusCode in [200, 400, 404] +    ✓ content-type check +    ✓ schema check + + ✓ POST /special-events - step create-event + +    Request URL: https://redocly.com/_mock/demo/openapi/museum-api/special-events +    Request Headers: +      content-type: application/json +      accept: application/json, application/problem+json +      authorization: Basic Og== +      multi-word-secret: composed Basic Og== +    Request Body: +      { +       "username": "John", +       "secret": "password", +       "multiwordSecret": "Bearer secretToken", +       "name": "Mermaid Treasure Identification and Analysis", +       "location": "Under the seaaa 🦀 🎶 🌊.", +       "eventDescription": "Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel.", +       "dates": [ +       "2023-09-05", +       "2023-09-08" +       ], +       "price": 0 +      } + + +    Response status code: 201 +    Response time: ms +    Response Headers: +    Response Body: +      { +       "eventId": "dad4bce8-f5cb-4078-a211-995864315e39", +       "name": "Mermaid Treasure Identification and Analysis", +       "location": "Under the seaaa 🦀 🎶 🌊.", +       "eventDescription": "Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel.", +       "dates": [ +       "2023-09-05", +       "2023-09-08" +       ], +       "price": 0 +      } + +    ✓ success criteria check - $statusCode == 201 +    ✓ success criteria check - $.name == 'Mermaid Treasure Identification and Ana... +    ✓ status code check - $statusCode in [201, 400, 404] +    ✓ content-type check +    ✓ schema check + + +  Summary for reveal-masked-input-secrets.arazzo.yaml +   +  Workflows: 2 passed, 2 total +  Steps: 3 passed, 3 total +  Checks: 12 passed, 12 total +  Time: ms + + +┌─────────────────────────────────────────────────────────────────────────────────┬────────────┬─────────┬─────────┬──────────┐ +│ Filename │ Workflows │ Passed │ Failed │ Warnings │ +├─────────────────────────────────────────────────────────────────────────────────┼────────────┼─────────┼─────────┼──────────┤ +│ ✓ reveal-masked-input-secrets.arazzo.yaml │ 2 │ 2 │ - │ - │ +└─────────────────────────────────────────────────────────────────────────────────┴────────────┴─────────┴─────────┴──────────┘ + + +" +`; diff --git a/__tests__/respect/reveal-masked-input-secrets/reveal-masked-input-secrets.arazzo.yaml b/__tests__/respect/reveal-masked-input-secrets/reveal-masked-input-secrets.arazzo.yaml new file mode 100644 index 0000000000..5301ab4f77 --- /dev/null +++ b/__tests__/respect/reveal-masked-input-secrets/reveal-masked-input-secrets.arazzo.yaml @@ -0,0 +1,118 @@ +arazzo: 1.0.1 +info: + title: Reveal masked input values + description: >- + Testing functionality of revealing masked input values in verbose output + version: 1.0.0 + +sourceDescriptions: + - name: museum-api + type: openapi + url: ../museum-api.yaml + - name: tickets-from-museum-api + type: arazzo + url: ../museum-tickets.yaml + +workflows: + - workflowId: get-museum-hours + inputs: + type: object + properties: + username: + type: string + env: + type: object + properties: + AUTH_TOKEN: + type: string + format: password + password: + type: string + format: password + description: additional password. + parameters: + - in: header + name: Authorization + value: $inputs.env.AUTH_TOKEN + - in: header + name: password + value: $inputs.password + - in: header + name: username + value: $inputs.username + - in: header + name: masked-combined-value + value: '{$inputs.username} and {$inputs.password} and maybe {$inputs.env.AUTH_TOKEN}' + description: >- + This workflow demonstrates how to get the museum opening hours and buy tickets. + steps: + - stepId: get-museum-hours + parameters: + - in: header + name: multi-word-secret + value: Bearer {$inputs.password} + description: >- + Get museum hours by resolving request details with getMuseumHours operationId from museum-api.yaml description. + operationId: $sourceDescriptions.museum-api.getMuseumHours + successCriteria: + - condition: $statusCode == 200 + outputs: + schedule: $response.body + - workflowId: events-crud + description: >- + This workflow demonstrates how to list, create, update, and delete special events at the museum. + parameters: + - in: header + name: Authorization + value: $inputs.env.AUTH_TOKEN + - in: header + name: multi-word-secret + value: composed {$inputs.env.AUTH_TOKEN} + inputs: + type: object + properties: + username: + type: string + env: + type: object + properties: + AUTH_TOKEN: + type: string + format: password + password: + type: string + format: password + secret: + type: object + properties: + secretValue: + type: string + format: password + steps: + - stepId: list-events + description: >- + Request the list of events. + operationPath: '{$sourceDescriptions.museum-api.url}#/paths/~1special-events/get' + outputs: + events: $response.body + - stepId: create-event + description: >- + Create a new special event. + operationPath: '{$sourceDescriptions.museum-api.url}#/paths/~1special-events/post' + requestBody: + payload: + username: $inputs.username + secret: $inputs.password + multiwordSecret: Bearer {$inputs.secret.secretValue} + name: 'Mermaid Treasure Identification and Analysis' + location: 'Under the seaaa 🦀 🎶 🌊.' + eventDescription: 'Join us as we review and classify a rare collection of 20 thingamabobs, gadgets, gizmos, whoosits, and whatsits, kindly donated by Ariel.' + dates: + - '2023-09-05' + - '2023-09-08' + price: 0 + successCriteria: + - condition: $statusCode == 201 + - context: $response.body + condition: $.name == 'Mermaid Treasure Identification and Analysis' + type: jsonpath diff --git a/__tests__/respect/reveal-masked-input-secrets/reveal-masked-input-secrets.test.ts b/__tests__/respect/reveal-masked-input-secrets/reveal-masked-input-secrets.test.ts new file mode 100644 index 0000000000..63b85fb75b --- /dev/null +++ b/__tests__/respect/reveal-masked-input-secrets/reveal-masked-input-secrets.test.ts @@ -0,0 +1,25 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getCommandOutput, getParams } from '../../helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +test('should reveal masked input values', () => { + process.env.AUTH_TOKEN = 'Basic Og=='; + + const indexEntryPoint = join(process.cwd(), 'packages/cli/lib/index.js'); + const fixturesPath = join(__dirname, 'reveal-masked-input-secrets.arazzo.yaml'); + const args = getParams(indexEntryPoint, [ + 'respect', + fixturesPath, + '--verbose', + '--input', + '{"username":"John","password":"password","secret": {"secretValue":"secretToken"}}', + '--no-secrets-masking', + ]); + + const result = getCommandOutput(args); + expect(result).toMatchSnapshot(); + + delete process.env.AUTH_TOKEN; +}, 60_000); diff --git a/docs/@v2/commands/respect.md b/docs/@v2/commands/respect.md index 1b9676c8bf..94ed0532bc 100644 --- a/docs/@v2/commands/respect.md +++ b/docs/@v2/commands/respect.md @@ -11,7 +11,7 @@ Use this command to execute API tests described in an Arazzo description. ## Usage ```sh -npx @redocly/cli@latest respect [-w | --workflow] [-s | --skip] [-v | --verbose] [-i | --input] [-S | --server] [-H | --har-output] [-J | --json-output] [--max-steps] [--max-fetch-timeout] [--execution-timeout] [--severity] +npx @redocly/cli@latest respect [-w | --workflow] [-s | --skip] [-v | --verbose] [-i | --input] [-S | --server] [-H | --har-output] [-J | --json-output] [--max-steps] [--max-fetch-timeout] [--execution-timeout] [--severity] [--no-secrets-masking] ``` ## Options @@ -180,6 +180,18 @@ npx @redocly/cli@latest respect { totalTimeMs: 100, totalRequests: 1, globalTimeoutError: false, + secretValues: [], }, ]); diff --git a/packages/cli/src/commands/respect/index.ts b/packages/cli/src/commands/respect/index.ts index e492a58ca3..cccdcb213f 100644 --- a/packages/cli/src/commands/respect/index.ts +++ b/packages/cli/src/commands/respect/index.ts @@ -30,6 +30,7 @@ export type RespectArgv = { config?: string; 'max-fetch-timeout': number; 'execution-timeout': number; + 'no-secrets-masking': boolean; }; export async function handleRespect({ @@ -42,7 +43,7 @@ export async function handleRespect({ let harLogs; try { - const { run, maskSecrets } = await import('@redocly/respect-core'); + const { run, conditionallyMaskSecrets } = await import('@redocly/respect-core'); const workingDir = config.configPath ? dirname(config.configPath) : process.cwd(); if (argv['client-cert'] || argv['client-key'] || argv['ca-cert']) { @@ -92,6 +93,7 @@ export async function handleRespect({ envVariables: readEnvVariables(workingDir) || {}, logger, fetch: customFetch as unknown as typeof fetch, + noSecretsMasking: argv['no-secrets-masking'], }; if (options.skip && options.workflow) { @@ -140,7 +142,11 @@ export async function handleRespect({ if (argv['har-output']) { // TODO: implement multiple run files HAR output for (const result of runAllFilesResult) { - const parsedHarLogs = maskSecrets(harLogs, result.ctx.secretFields || new Set()); + const parsedHarLogs = conditionallyMaskSecrets({ + value: harLogs, + noSecretsMasking: result.ctx.noSecretsMasking, + secretsSet: result.ctx.secretsSet || new Set(), + }); writeFileSync(argv['har-output'], jsonStringifyWithArrayBuffer(parsedHarLogs, 2), 'utf-8'); logger.output(blue(`Har logs saved in ${green(argv['har-output'])}`)); logger.printNewLine(); diff --git a/packages/cli/src/commands/respect/json-logs.ts b/packages/cli/src/commands/respect/json-logs.ts index 417ef1e518..383b1fce05 100644 --- a/packages/cli/src/commands/respect/json-logs.ts +++ b/packages/cli/src/commands/respect/json-logs.ts @@ -1,4 +1,4 @@ -import { maskSecrets, calculateTotals } from '@redocly/respect-core'; +import { calculateTotals, conditionallyMaskSecrets } from '@redocly/respect-core'; import type { TestContext, @@ -18,23 +18,30 @@ export function composeJsonLogsFiles( executedWorkflows: WorkflowExecutionResult[]; ctx: TestContext; globalTimeoutError: boolean; + secretValues?: string[]; }[] ): JsonLogs['files'] { const files: JsonLogs['files'] = {}; for (const fileResult of filesResult) { - const { executedWorkflows, globalTimeoutError: fileGlobalTimeoutError } = fileResult; - const { secretFields } = fileResult.ctx; + const { + executedWorkflows, + globalTimeoutError: fileGlobalTimeoutError, + ctx, + secretValues, + } = fileResult; - files[fileResult.file] = maskSecrets( - { + files[fileResult.file] = conditionallyMaskSecrets({ + value: { totalRequests: fileResult.totalRequests, executedWorkflows: executedWorkflows.map((workflow) => mapJsonWorkflow(workflow)), totalTimeMs: fileResult.totalTimeMs, globalTimeoutError: fileGlobalTimeoutError, + secretValues, }, - secretFields || new Set() - ); + noSecretsMasking: ctx.noSecretsMasking, + secretsSet: ctx.secretsSet || new Set(), + }); } return files; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e4fe9c7dfc..b6a1274663 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -42,7 +42,7 @@ cacheLatestVersion(); yargs(hideBin(process.argv)) .version('version', 'Show version number.', version) .help('help', 'Show help.') - .parserConfiguration({ 'greedy-arrays': false }) + .parserConfiguration({ 'greedy-arrays': false, 'boolean-negation': false }) .command( 'stats [api]', 'Show statistics for an API description.', @@ -764,6 +764,11 @@ yargs(hideBin(process.argv)) default: 3_600_000, coerce: validatePositiveNumber('execution-timeout', false), }, + 'no-secrets-masking': { + describe: 'Do not mask secrets in the output.', + type: 'boolean', + default: false, + }, }); }, async (argv) => { diff --git a/packages/respect-core/src/index.ts b/packages/respect-core/src/index.ts index 0943534613..8e7c27756e 100644 --- a/packages/respect-core/src/index.ts +++ b/packages/respect-core/src/index.ts @@ -1,7 +1,7 @@ export { generate, type GenerateArazzoOptions } from './generate.js'; export { run, type RespectOptions } from './run.js'; export * from './types.js'; -export { maskSecrets } from './modules/logger-output/mask-secrets.js'; +export { maskSecrets, conditionallyMaskSecrets } from './modules/logger-output/mask-secrets.js'; export { calculateTotals } from './modules/logger-output/calculate-tests-passed.js'; export { RESET_ESCAPE_CODE } from './modules/logger-output/helpers.js'; export { parseRuntimeExpression } from './modules/runtime-expressions/index.js'; diff --git a/packages/respect-core/src/modules/__tests__/config-parser/get-security-parameters.test.ts b/packages/respect-core/src/modules/__tests__/config-parser/get-security-parameters.test.ts index ecd7c55ae7..ea2f8cf5bd 100644 --- a/packages/respect-core/src/modules/__tests__/config-parser/get-security-parameters.test.ts +++ b/packages/respect-core/src/modules/__tests__/config-parser/get-security-parameters.test.ts @@ -5,7 +5,7 @@ import type { TestContext } from '../../../types'; describe('getSecurityParameter', () => { const ctx = { - secretFields: new Set(), + secretsSet: new Set(), } as TestContext; it('should return security parameters for API Key Auth', () => { diff --git a/packages/respect-core/src/modules/__tests__/flow-runner/call-api-and-analyze-results.test.ts b/packages/respect-core/src/modules/__tests__/flow-runner/call-api-and-analyze-results.test.ts index bfb02fa143..beb5d4e838 100644 --- a/packages/respect-core/src/modules/__tests__/flow-runner/call-api-and-analyze-results.test.ts +++ b/packages/respect-core/src/modules/__tests__/flow-runner/call-api-and-analyze-results.test.ts @@ -339,7 +339,7 @@ describe('callAPIAndAnalyzeResults', () => { }, ], $outputs: {}, - secretFields: new Set(), + secretsSet: new Set(), } as unknown as TestContext; it('should call API and return checks result', async () => { diff --git a/packages/respect-core/src/modules/__tests__/flow-runner/context/create-test-context.test.ts b/packages/respect-core/src/modules/__tests__/flow-runner/context/create-test-context.test.ts index 5d651ebacc..ddb37be03d 100644 --- a/packages/respect-core/src/modules/__tests__/flow-runner/context/create-test-context.test.ts +++ b/packages/respect-core/src/modules/__tests__/flow-runner/context/create-test-context.test.ts @@ -5,7 +5,7 @@ import { ApiFetcher } from '../../../../utils/api-fetcher.js'; import { createTestContext, DEFAULT_SEVERITY_CONFIGURATION, - collectSecretFields, + collectSecretValues, } from '../../../../modules/flow-runner/index.js'; describe('createTestContext', () => { @@ -524,7 +524,7 @@ describe('createTestContext', () => { }, info: { title: 'API', version: '1.0' }, arazzo: '1.0.1', - secretFields: {}, + secretsSet: {}, severity: DEFAULT_SEVERITY_CONFIGURATION, sourceDescriptions: [ { name: 'cats', type: 'openapi', url: '../../__tests__/respect/cat-fact-api/cats.yaml' }, @@ -762,10 +762,10 @@ describe('createTestContext', () => { }); }); -describe('storeSecretFields', () => { +describe('storeSecretValues', () => { it('should store secret fields with inputs', () => { const ctx = { - secretFields: new Set(), + secretsSet: new Set(), } as unknown as TestContext; const schema = { @@ -788,14 +788,14 @@ describe('storeSecretFields', () => { }, }; - collectSecretFields(ctx, schema, inputs); + collectSecretValues(ctx, schema, inputs); - expect(ctx.secretFields).toEqual(new Set(['password', 'nested-password'])); + expect(ctx.secretsSet).toEqual(new Set(['password', 'nested-password'])); }); it('should store secret env fields', () => { const ctx = { - secretFields: new Set(), + secretsSet: new Set(), } as unknown as TestContext; const schema = { @@ -825,14 +825,14 @@ describe('storeSecretFields', () => { }, }; - collectSecretFields(ctx, schema, inputs); + collectSecretValues(ctx, schema, inputs); - expect(ctx.secretFields).toEqual(new Set(['password', 'nested-password'])); + expect(ctx.secretsSet).toEqual(new Set(['password', 'nested-password'])); }); it('should not store secret fields if not password format', () => { const ctx = { - secretFields: new Set(), + secretsSet: new Set(), } as unknown as TestContext; const schema = { @@ -855,14 +855,14 @@ describe('storeSecretFields', () => { }, }; - collectSecretFields(ctx, schema, inputs); + collectSecretValues(ctx, schema, inputs); - expect(ctx.secretFields).toEqual(new Set()); + expect(ctx.secretsSet).toEqual(new Set()); }); it('should store secret field with format password in object', () => { const ctx = { - secretFields: new Set(), + secretsSet: new Set(), } as unknown as TestContext; const schema = { @@ -874,8 +874,8 @@ describe('storeSecretFields', () => { tocken: 'secret-token', }; - collectSecretFields(ctx, schema, inputs, ['tocken']); + collectSecretValues(ctx, schema, inputs, ['tocken']); - expect(ctx.secretFields).toEqual(new Set(['secret-token'])); + expect(ctx.secretsSet).toEqual(new Set(['secret-token'])); }); }); diff --git a/packages/respect-core/src/modules/__tests__/flow-runner/resolve-x-security-parameters.ts.test.ts b/packages/respect-core/src/modules/__tests__/flow-runner/resolve-x-security-parameters.ts.test.ts index a5e715231e..1ca7dd2370 100644 --- a/packages/respect-core/src/modules/__tests__/flow-runner/resolve-x-security-parameters.ts.test.ts +++ b/packages/respect-core/src/modules/__tests__/flow-runner/resolve-x-security-parameters.ts.test.ts @@ -5,7 +5,7 @@ import type { Step, RuntimeExpressionContext, TestContext } from 'respect-core/s describe('resolveXSecurityParameters', () => { const ctx = { - secretFields: new Set(), + secretsSet: new Set(), options: { logger, }, diff --git a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts index b3776fff72..76f8b8b3d7 100644 --- a/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts +++ b/packages/respect-core/src/modules/__tests__/logger-output/mask-secrets.test.ts @@ -2,6 +2,7 @@ import { findPotentiallySecretObjectFields, containsSecret, maskSecrets, + conditionallyMaskSecrets, } from '../../logger-output/mask-secrets.js'; describe('findPotentiallySecretObjectFields', () => { @@ -128,3 +129,23 @@ describe('maskSecrets', () => { expect(result.nested.password).toBe('********'); }); }); + +describe('conditionallyMaskSecrets', () => { + it('should mask secrets if noSecretsMasking is false', () => { + const result = conditionallyMaskSecrets({ + value: 'token123456', + noSecretsMasking: false, + secretsSet: new Set(['token123456']), + }); + expect(result).toEqual('********'); + }); + + it('should preserve secrets if noSecretsMasking is true', () => { + const result = conditionallyMaskSecrets({ + value: 'token123456', + noSecretsMasking: true, + secretsSet: new Set(['token123456']), + }); + expect(result).toEqual('token123456'); + }); +}); diff --git a/packages/respect-core/src/modules/context-parser/get-security-parameters.ts b/packages/respect-core/src/modules/context-parser/get-security-parameters.ts index 49429d5344..72c5c2c7ef 100644 --- a/packages/respect-core/src/modules/context-parser/get-security-parameters.ts +++ b/packages/respect-core/src/modules/context-parser/get-security-parameters.ts @@ -25,7 +25,7 @@ export function getSecurityParameter( const { username, password } = security.values; const value = btoa(`${username}:${password}`); - ctx.secretFields.add(value); + ctx.secretsSet.add(value); return getAuthHeader(`Basic ${value}`, ctx); } @@ -33,7 +33,7 @@ export function getSecurityParameter( if (isBearerAuth(security)) { const { token } = security.values; - ctx.secretFields.add(token); + ctx.secretsSet.add(token); return getAuthHeader(`Bearer ${token}`, ctx); } @@ -41,7 +41,7 @@ export function getSecurityParameter( if (isOpenIdConnectAuth(security)) { const { accessToken } = security.values; - ctx.secretFields.add(accessToken); + ctx.secretsSet.add(accessToken); return getAuthHeader(`Bearer ${accessToken}`, ctx); } @@ -49,7 +49,7 @@ export function getSecurityParameter( if (isOAuth2Auth(security)) { const { accessToken } = security.values; - ctx.secretFields.add(accessToken); + ctx.secretsSet.add(accessToken); return getAuthHeader(`Bearer ${accessToken}`, ctx); } diff --git a/packages/respect-core/src/modules/flow-runner/context/create-test-context.ts b/packages/respect-core/src/modules/flow-runner/context/create-test-context.ts index b237155529..80cb91aee5 100644 --- a/packages/respect-core/src/modules/flow-runner/context/create-test-context.ts +++ b/packages/respect-core/src/modules/flow-runner/context/create-test-context.ts @@ -94,7 +94,8 @@ export async function createTestContext( info: testDescription.info || infoSubstitute, arazzo: testDescription.arazzo || '', sourceDescriptions: testDescription.sourceDescriptions || [], - secretFields: new Set(), + noSecretsMasking: options.noSecretsMasking || false, + secretsSet: new Set(), severity: resolveSeverityConfiguration(options.severity), apiClient, }; @@ -102,14 +103,14 @@ export async function createTestContext( // Collect all secret fields from the input schema and the workflow inputs for (const workflow of testDescription.workflows || []) { if (workflow.inputs) { - collectSecretFields(ctx, workflow.inputs, ctx.$workflows[workflow.workflowId].inputs); + collectSecretValues(ctx, workflow.inputs, ctx.$workflows[workflow.workflowId].inputs); } } return ctx; } -export function collectSecretFields( +export function collectSecretValues( ctx: TestContext, schema: InputSchema | undefined, inputs: Record | undefined, @@ -120,13 +121,13 @@ export function collectSecretFields( const inputValue = getNestedValue(inputs, path); if (schema.format === 'password' && inputValue) { - ctx.secretFields.add(inputValue); + ctx.secretsSet.add(inputValue); } if (schema.properties) { Object.entries(schema.properties).forEach(([key, value]: [string, any]) => { const currentPath = [...path, key]; - collectSecretFields(ctx, value, inputs, currentPath); + collectSecretValues(ctx, value, inputs, currentPath); }); } } diff --git a/packages/respect-core/src/modules/flow-runner/prepare-request.ts b/packages/respect-core/src/modules/flow-runner/prepare-request.ts index 27c92996d6..fb1533606f 100644 --- a/packages/respect-core/src/modules/flow-runner/prepare-request.ts +++ b/packages/respect-core/src/modules/flow-runner/prepare-request.ts @@ -10,7 +10,7 @@ import { handlePayloadReplacements, } from '../context-parser/index.js'; import { getServerUrl } from './get-server-url.js'; -import { createRuntimeExpressionCtx, collectSecretFields } from './context/index.js'; +import { createRuntimeExpressionCtx, collectSecretValues } from './context/index.js'; import { evaluateRuntimeExpressionPayload } from '../runtime-expressions/index.js'; import { resolveXSecurityParameters } from './resolve-x-security-parameters.js'; @@ -168,7 +168,7 @@ export async function prepareRequest( for (const param of openapiOperation?.parameters || []) { const { schema, name } = param; - collectSecretFields(ctx, schema, groupParametersValuesByName(parameters, param.in), [name]); + collectSecretValues(ctx, schema, groupParametersValuesByName(parameters, param.in), [name]); } const evaluatedBody = evaluateRuntimeExpressionPayload({ @@ -190,7 +190,7 @@ export async function prepareRequest( if (contentType && openapiOperation?.requestBody) { const requestBodySchema = getRequestBodySchema(contentType, openapiOperation); if (typeof requestBody === 'object') { - collectSecretFields(ctx, requestBodySchema, requestBody); + collectSecretValues(ctx, requestBodySchema, requestBody); } } diff --git a/packages/respect-core/src/modules/logger-output/mask-secrets.ts b/packages/respect-core/src/modules/logger-output/mask-secrets.ts index 701e449995..85d426910e 100644 --- a/packages/respect-core/src/modules/logger-output/mask-secrets.ts +++ b/packages/respect-core/src/modules/logger-output/mask-secrets.ts @@ -10,7 +10,7 @@ export const POTENTIALLY_SECRET_FIELDS = [ export function maskSecrets( target: T, - secretValues: Set + secretsSet: Set ): T { const maskValue = (value: string, secret: string): string => { return value.replace(secret, '*'.repeat(8)); @@ -18,7 +18,7 @@ export function maskSecrets( if (typeof target === 'string') { let maskedString = target as string; - secretValues.forEach((secret) => { + secretsSet.forEach((secret) => { maskedString = maskedString.split(secret).join('*'.repeat(8)); }); return maskedString as T; @@ -28,7 +28,7 @@ export function maskSecrets( const maskIfContainsSecret = (value: string): string => { let maskedValue = value; - for (const secret of secretValues) { + for (const secret of secretsSet) { if (maskedValue.includes(secret)) { maskedValue = maskValue(maskedValue, secret); } @@ -65,8 +65,8 @@ export function maskSecrets( return masked; } -export function containsSecret(value: string, secretValues: Set): boolean { - return Array.from(secretValues).some((secret) => value.includes(secret)); +export function containsSecret(value: string, secretsSet: Set): boolean { + return Array.from(secretsSet).some((secret) => value.includes(secret)); } export function findPotentiallySecretObjectFields( @@ -120,3 +120,15 @@ export function findPotentiallySecretObjectFields( searchInObject(obj); return foundTokens; } + +export function conditionallyMaskSecrets({ + value, + noSecretsMasking, + secretsSet, +}: { + value: T; + noSecretsMasking: boolean; + secretsSet: Set; +}): T { + return noSecretsMasking ? value : maskSecrets(value, secretsSet); +} diff --git a/packages/respect-core/src/run.ts b/packages/respect-core/src/run.ts index 3c885b42b1..a8f24a7bb8 100644 --- a/packages/respect-core/src/run.ts +++ b/packages/respect-core/src/run.ts @@ -29,6 +29,7 @@ export type RespectOptions = { fetch: typeof fetch; externalRefResolver?: BaseResolver; skipLint?: boolean; + noSecretsMasking?: boolean; }; export async function run(options: RespectOptions): Promise { @@ -94,5 +95,6 @@ async function runFile({ totalTimeMs: performance.now() - startedAt, totalRequests: totals.totalRequests, globalTimeoutError: hasGlobalTimeoutError, + ...(ctx.noSecretsMasking && { secretValues: Array.from(ctx.secretsSet) || [] }), }; } diff --git a/packages/respect-core/src/types.ts b/packages/respect-core/src/types.ts index 83ec3991b7..699c376c3c 100644 --- a/packages/respect-core/src/types.ts +++ b/packages/respect-core/src/types.ts @@ -90,6 +90,7 @@ export type RunOptions = Omit & { input?: string | string[]; server?: string | string[]; severity?: string | string[]; + noSecretsMasking?: boolean; }; export type CommandArgs = { @@ -133,6 +134,7 @@ export type AppOptions = { logger: LoggerInterface; externalRefResolver?: BaseResolver; skipLint?: boolean; + noSecretsMasking?: boolean; }; export type RegexpSuccessCriteria = { condition: string; @@ -250,6 +252,7 @@ export type RunFileResult = { totalTimeMs: number; totalRequests: number; globalTimeoutError: boolean; + secretValues?: string[]; }; export interface WorkflowExecutionResult { @@ -284,7 +287,8 @@ export type TestContext = RuntimeExpressionContext & { options: AppOptions; testDescription: TestDescription; components?: Record; - secretFields: Set; + secretsSet: Set; + noSecretsMasking: boolean; severity: Record; apiClient: ApiFetcher; }; @@ -330,6 +334,9 @@ export type JsonLogs = { { totalRequests: number; executedWorkflows: WorkflowExecutionResultJson[]; + totalTimeMs: number; + globalTimeoutError: boolean; + secretValues?: string[]; } >; status: string; diff --git a/packages/respect-core/src/utils/api-fetcher.ts b/packages/respect-core/src/utils/api-fetcher.ts index d43aacee13..96c50b9da1 100644 --- a/packages/respect-core/src/utils/api-fetcher.ts +++ b/packages/respect-core/src/utils/api-fetcher.ts @@ -10,11 +10,11 @@ import { isEmpty } from './is-empty.js'; import { resolvePath } from '../modules/context-parser/index.js'; import { getVerboseLogs, - maskSecrets, + conditionallyMaskSecrets, findPotentiallySecretObjectFields, } from '../modules/logger-output/index.js'; import { getResponseSchema } from '../modules/description-parser/index.js'; -import { collectSecretFields } from '../modules/flow-runner/index.js'; +import { collectSecretValues } from '../modules/flow-runner/index.js'; import { parseWwwAuthenticateHeader } from './digest-auth/parse-www-authenticate-header.js'; import { generateDigestAuthHeader } from './digest-auth/generate-digest-auth-header.js'; import { isBinaryContentType } from './binary-content-type-checker.js'; @@ -217,7 +217,11 @@ export class ApiFetcher implements IFetcher { } // Mask the secrets in the header params and the body - const maskedHeaderParams = maskSecrets(headers, ctx.secretFields); + const maskedHeaderParams = conditionallyMaskSecrets({ + value: headers, + noSecretsMasking: ctx.noSecretsMasking, + secretsSet: ctx.secretsSet, + }); let maskedBody; if (encodedBody instanceof FormData) { @@ -227,17 +231,29 @@ export class ApiFetcher implements IFetcher { if (value instanceof File) { maskedFormData.append(key, value); } else { - const maskedValue = maskSecrets(value, ctx.secretFields); + const maskedValue = conditionallyMaskSecrets({ + value: value, + noSecretsMasking: ctx.noSecretsMasking, + secretsSet: ctx.secretsSet, + }); maskedFormData.append(key, maskedValue); } } maskedBody = maskedFormData; } else if (isJsonContentType(contentType) && encodedBody) { - maskedBody = maskSecrets(JSON.parse(encodedBody), ctx.secretFields); + maskedBody = conditionallyMaskSecrets({ + value: JSON.parse(encodedBody), + noSecretsMasking: ctx.noSecretsMasking, + secretsSet: ctx.secretsSet, + }); } else { maskedBody = encodedBody; } - const maskedPathParams = maskSecrets(pathWithSearchParams, ctx.secretFields); + const maskedPathParams = conditionallyMaskSecrets({ + value: pathWithSearchParams, + noSecretsMasking: ctx.noSecretsMasking, + secretsSet: ctx.secretsSet, + }); // Start of the verbose logs this.initVerboseLogs({ @@ -352,7 +368,11 @@ export class ApiFetcher implements IFetcher { } this.updateVerboseLogs({ - headerParams: maskSecrets(updatedHeaders, ctx.secretFields), + headerParams: conditionallyMaskSecrets({ + value: updatedHeaders, + noSecretsMasking: ctx.noSecretsMasking, + secretsSet: ctx.secretsSet, + }), }); fetchResult = await customFetch(urlToFetch, { @@ -393,22 +413,26 @@ export class ApiFetcher implements IFetcher { descriptionResponses: openapiOperation?.responses, }); - collectSecretFields(ctx, responseSchema, transformedBody); + collectSecretValues(ctx, responseSchema, transformedBody); const foundResponseBodySecrets = findPotentiallySecretObjectFields(transformedBody); for (const secretItem of foundResponseBodySecrets) { - ctx.secretFields.add(secretItem); + ctx.secretsSet.add(secretItem); } const maskedResponseBody = isJsonContentType(responseContentType) - ? maskSecrets(transformedBody, ctx.secretFields) + ? conditionallyMaskSecrets({ + value: transformedBody, + noSecretsMasking: ctx.noSecretsMasking, + secretsSet: ctx.secretsSet, + }) : transformedBody; const responseHeaders = Object.fromEntries(fetchResult.headers?.entries() || []); const foundResponseHeadersSecrets = findPotentiallySecretObjectFields(responseHeaders); for (const secretItem of foundResponseHeadersSecrets) { - ctx.secretFields.add(secretItem); + ctx.secretsSet.add(secretItem); } this.initVerboseResponseLogs({ @@ -420,7 +444,11 @@ export class ApiFetcher implements IFetcher { path: pathWithSearchParams || '', statusCode: fetchResult.status, responseTime, - headerParams: maskSecrets(responseHeaders, ctx.secretFields), + headerParams: conditionallyMaskSecrets({ + value: responseHeaders, + noSecretsMasking: ctx.noSecretsMasking, + secretsSet: ctx.secretsSet, + }), }); return {