Skip to content

Commit 0bacc80

Browse files
feat: mask potentially secret fields from response (#2164)
1 parent 4c9c055 commit 0bacc80

File tree

4 files changed

+165
-2
lines changed

4 files changed

+165
-2
lines changed

.changeset/warm-suns-try.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@redocly/respect-core": minor
3+
"@redocly/cli": minor
4+
---
5+
6+
Implemented automatic masking of sensitive fields (such as tokens and passwords) in response bodies to enhance security and prevent accidental exposure of secrets in logs and outputs.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
findPotentiallySecretObjectFields,
3+
containsSecret,
4+
maskSecrets,
5+
} from '../../cli-output/mask-secrets';
6+
7+
describe('findPotentiallySecretObjectFields', () => {
8+
it('should find potentially secret object fields', () => {
9+
const obj = {
10+
token: 'token123456',
11+
accessToken: 'accessToken789',
12+
idToken: 'idToken012',
13+
password: 'password345',
14+
access_token: 'access_token678',
15+
id_token: 'id_token901',
16+
accessToken2: 'accessToken234',
17+
idToken2: 'idToken567',
18+
client_secret: 'some_client_secret',
19+
access_token2: 'access_token123',
20+
id_token2: 'id_token456',
21+
name: 'John Doe',
22+
age: 30,
23+
isAdmin: true,
24+
address: {
25+
street: '123 Main St',
26+
city: 'Anytown',
27+
zip: '12345',
28+
personal: {
29+
favoritePassword: 'favoritePassword123',
30+
password: 'password123',
31+
color: 'red',
32+
},
33+
},
34+
};
35+
36+
const result = findPotentiallySecretObjectFields(obj);
37+
expect(result).toEqual([
38+
'token123456',
39+
'password345',
40+
'access_token678',
41+
'id_token901',
42+
'some_client_secret',
43+
'password123',
44+
]);
45+
});
46+
});
47+
48+
describe('containsSecret', () => {
49+
it('should return true if the value contains a secret', () => {
50+
const result = containsSecret('token123456', new Set(['token123456']));
51+
expect(result).toBe(true);
52+
});
53+
54+
it('should return false if the value does not contain a secret', () => {
55+
const result = containsSecret('token123456', new Set(['token123456']));
56+
expect(result).toBe(true);
57+
});
58+
59+
it('should return true if the value contains a secret in different casing', () => {
60+
const result = containsSecret('token123456', new Set(['token123456']));
61+
expect(result).toBe(true);
62+
});
63+
});
64+
65+
describe('maskSecrets', () => {
66+
it('should mask secrets', () => {
67+
const result = maskSecrets('token123456', new Set(['token123456']));
68+
expect(result).toEqual('********');
69+
});
70+
71+
it('should mask secrets in different casing', () => {
72+
const result = maskSecrets('token123456', new Set(['token123456']));
73+
expect(result).toEqual('********');
74+
});
75+
76+
it('should mask secrets in nested object', () => {
77+
const result = maskSecrets(
78+
{ token: 'token123456', nested: { password: 'password123456' } },
79+
new Set(['token123456'])
80+
);
81+
expect(result).toEqual({
82+
nested: {
83+
password: 'password123456',
84+
},
85+
token: '********',
86+
});
87+
});
88+
89+
it('should mask secrets in array', () => {
90+
const result = maskSecrets(['token123456', 'password123456'], new Set(['token123456']));
91+
expect(result).toEqual(['********', 'password123456']);
92+
});
93+
94+
it('should mask secrets in string', () => {
95+
const result = maskSecrets('Bearer token123456', new Set(['token123456']));
96+
expect(result).toEqual('Bearer ********');
97+
});
98+
});

packages/respect-core/src/modules/cli-output/mask-secrets.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
export const POTENTIALLY_SECRET_FIELDS = [
2+
'token',
3+
'access_token',
4+
'id_token',
5+
'password',
6+
'client_secret',
7+
];
8+
19
export function maskSecrets<T extends { [x: string]: any } | string>(
210
target: T,
311
secretValues: Set<string>
@@ -9,7 +17,7 @@ export function maskSecrets<T extends { [x: string]: any } | string>(
917
if (typeof target === 'string') {
1018
let maskedString = target as string;
1119
secretValues.forEach((secret) => {
12-
maskedString = maskedString.split(secret).join('*'.repeat(secret.length));
20+
maskedString = maskedString.split(secret).join('*'.repeat(8));
1321
});
1422
return maskedString as T;
1523
}
@@ -44,3 +52,45 @@ export function maskSecrets<T extends { [x: string]: any } | string>(
4452
export function containsSecret(value: string, secretValues: Set<string>): boolean {
4553
return Array.from(secretValues).some((secret) => value.includes(secret));
4654
}
55+
56+
export function findPotentiallySecretObjectFields(
57+
obj: any,
58+
tokenKeys: string[] = POTENTIALLY_SECRET_FIELDS
59+
): string[] {
60+
const foundTokens: string[] = [];
61+
62+
if (!obj || typeof obj !== 'object') {
63+
return foundTokens;
64+
}
65+
66+
const searchInObject = (currentObj: any) => {
67+
if (!currentObj || typeof currentObj !== 'object') {
68+
return;
69+
}
70+
71+
if (Array.isArray(currentObj)) {
72+
for (const item of currentObj) {
73+
searchInObject(item);
74+
}
75+
return;
76+
}
77+
78+
for (const key in currentObj) {
79+
const value = currentObj[key];
80+
81+
// Check if the key matches any of the token keys (case-insensitive)
82+
if (tokenKeys.some((tokenKey) => tokenKey.toLowerCase() === key.toLowerCase())) {
83+
if (typeof value === 'string' && value.trim()) {
84+
foundTokens.push(value);
85+
}
86+
}
87+
88+
if (value && typeof value === 'object') {
89+
searchInObject(value);
90+
}
91+
}
92+
};
93+
94+
searchInObject(obj);
95+
return foundTokens;
96+
}

packages/respect-core/src/utils/api-fetcher.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import {
1313
import { withHar } from '../utils/har-logs/index.js';
1414
import { isEmpty } from './is-empty.js';
1515
import { resolvePath } from '../modules/context-parser/index.js';
16-
import { getVerboseLogs, maskSecrets } from '../modules/cli-output/index.js';
16+
import {
17+
getVerboseLogs,
18+
maskSecrets,
19+
findPotentiallySecretObjectFields,
20+
} from '../modules/cli-output/index.js';
1721
import { getResponseSchema } from '../modules/description-parser/index.js';
1822
import { collectSecretFields } from '../modules/flow-runner/index.js';
1923
import { createMtlsClient } from './mtls/create-mtls-client.js';
@@ -374,6 +378,11 @@ export class ApiFetcher implements IFetcher {
374378

375379
collectSecretFields(ctx, responseSchema, transformedBody);
376380

381+
const foundResponseBodySecrets = findPotentiallySecretObjectFields(transformedBody);
382+
for (const secretItem of foundResponseBodySecrets) {
383+
ctx.secretFields.add(secretItem);
384+
}
385+
377386
const maskedResponseBody = isJsonContentType(responseContentType)
378387
? maskSecrets(transformedBody, ctx.secretFields || new Set())
379388
: transformedBody;

0 commit comments

Comments
 (0)