Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ async function main(argv: string[]) {
"Request headers to redact (values will be replaced by XXXX)",
commaSeparatedList,
)
.option(
"--redact-body-fields <fields>",
"JSON body fields to redact in request and response bodies (values will be replaced by XXXX)",
commaSeparatedList,
)
.option(
"--no-drop-conditional-request-headers",
"When running in record mode, by default, `If-*` headers from outgoing requests are dropped in an attempt to prevent the suite of conditional responses being returned (e.g. 304). Supplying this flag disables this default behaviour",
Expand Down Expand Up @@ -112,6 +117,7 @@ async function main(argv: string[]) {
const sendProxyPort: boolean =
options.sendProxyPort === undefined ? false : options.sendProxyPort;
const redactHeaders: string[] = options.redactHeaders;
const redactBodyFields: string[] = options.redactBodyFields;
const preventConditionalRequests: boolean =
!!options.dropConditionalRequestHeaders;
const httpsCA: string = options.httpsCa || "";
Expand Down Expand Up @@ -178,6 +184,7 @@ async function main(argv: string[]) {
defaultTapeName,
enableLogging: true,
redactHeaders,
redactBodyFields,
preventConditionalRequests,
httpsCA,
httpsKey,
Expand Down
255 changes: 254 additions & 1 deletion src/persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { brotliCompressSync, gzipSync } from "zlib";
import { persistTape, reviveTape, redactRequestHeaders } from "./persistence";
import {
persistTape,
reviveTape,
redactRequestHeaders,
redactBodyFields,
} from "./persistence";

// Note the repetition. This is necessary otherwise Brotli compression
// will be null.
Expand Down Expand Up @@ -504,3 +509,251 @@ describe("Persistence", () => {
});
});
});

describe("Body Field Redaction", () => {
it("redacts simple JSON fields in request body", () => {
const requestJson = JSON.stringify({
email: "[email protected]",
password: "secret123",
username: "testuser",
});

const record = {
request: {
method: "POST",
path: "/login",
headers: {},
body: Buffer.from(requestJson, "utf8"),
},
response: {
status: { code: 200 },
headers: {},
body: Buffer.from("{}", "utf8"),
},
};

redactBodyFields(record, ["password"]);

const redactedRequest = JSON.parse(record.request.body.toString("utf8"));
expect(redactedRequest.email).toEqual("[email protected]");
expect(redactedRequest.password).toEqual("XXXX");
expect(redactedRequest.username).toEqual("testuser");
});

it("redacts fields case-insensitively", () => {
const requestJson = JSON.stringify({
Password: "secret123",
ACCESS_TOKEN: "token456",
});

const record = {
request: {
method: "POST",
path: "/login",
headers: {},
body: Buffer.from(requestJson, "utf8"),
},
response: {
status: { code: 200 },
headers: {},
body: Buffer.from("{}", "utf8"),
},
};

redactBodyFields(record, ["password", "access_token"]);

const redactedRequest = JSON.parse(record.request.body.toString("utf8"));
expect(redactedRequest.Password).toEqual("XXXX");
expect(redactedRequest.ACCESS_TOKEN).toEqual("XXXX");
});

it("redacts nested JSON fields", () => {
const requestJson = JSON.stringify({
user: {
email: "[email protected]",
credentials: {
password: "secret123",
api_key: "key789",
},
},
});

const record = {
request: {
method: "POST",
path: "/api/user",
headers: {},
body: Buffer.from(requestJson, "utf8"),
},
response: {
status: { code: 200 },
headers: {},
body: Buffer.from("{}", "utf8"),
},
};

redactBodyFields(record, ["password", "api_key"]);

const redactedRequest = JSON.parse(record.request.body.toString("utf8"));
expect(redactedRequest.user.email).toEqual("[email protected]");
expect(redactedRequest.user.credentials.password).toEqual("XXXX");
expect(redactedRequest.user.credentials.api_key).toEqual("XXXX");
});

it("redacts fields in arrays", () => {
const requestJson = JSON.stringify({
users: [
{ username: "user1", password: "pass1" },
{ username: "user2", password: "pass2" },
],
});

const record = {
request: {
method: "POST",
path: "/api/users",
headers: {},
body: Buffer.from(requestJson, "utf8"),
},
response: {
status: { code: 200 },
headers: {},
body: Buffer.from("{}", "utf8"),
},
};

redactBodyFields(record, ["password"]);

const redactedRequest = JSON.parse(record.request.body.toString("utf8"));
expect(redactedRequest.users[0].username).toEqual("user1");
expect(redactedRequest.users[0].password).toEqual("XXXX");
expect(redactedRequest.users[1].username).toEqual("user2");
expect(redactedRequest.users[1].password).toEqual("XXXX");
});

it("redacts fields in response body", () => {
const responseJson = JSON.stringify({
user: {
id: 123,
access_token: "token123",
refresh_token: "refresh456",
},
});

const record = {
request: {
method: "POST",
path: "/login",
headers: {},
body: Buffer.from("{}", "utf8"),
},
response: {
status: { code: 200 },
headers: {},
body: Buffer.from(responseJson, "utf8"),
},
};

redactBodyFields(record, ["access_token", "refresh_token"]);

const redactedResponse = JSON.parse(record.response.body.toString("utf8"));
expect(redactedResponse.user.id).toEqual(123);
expect(redactedResponse.user.access_token).toEqual("XXXX");
expect(redactedResponse.user.refresh_token).toEqual("XXXX");
});

it("does not modify non-JSON bodies", () => {
const plainText = "This is plain text, not JSON";

const record = {
request: {
method: "POST",
path: "/text",
headers: {},
body: Buffer.from(plainText, "utf8"),
},
response: {
status: { code: 200 },
headers: {},
body: Buffer.from("OK", "utf8"),
},
};

redactBodyFields(record, ["password"]);

expect(record.request.body.toString("utf8")).toEqual(plainText);
expect(record.response.body.toString("utf8")).toEqual("OK");
});

it("does not modify binary bodies", () => {
const record = {
request: {
method: "POST",
path: "/binary",
headers: {},
body: BINARY_REQUEST,
},
response: {
status: { code: 200 },
headers: {},
body: BINARY_RESPONSE,
},
};

const originalRequest = Buffer.from(BINARY_REQUEST);
const originalResponse = Buffer.from(BINARY_RESPONSE);

redactBodyFields(record, ["password"]);

expect(record.request.body).toEqual(originalRequest);
expect(record.response.body).toEqual(originalResponse);
});

it("handles empty body gracefully", () => {
const record = {
request: {
method: "GET",
path: "/empty",
headers: {},
body: Buffer.from("", "utf8"),
},
response: {
status: { code: 200 },
headers: {},
body: Buffer.from("", "utf8"),
},
};

redactBodyFields(record, ["password"]);

expect(record.request.body.toString("utf8")).toEqual("");
expect(record.response.body.toString("utf8")).toEqual("");
});

it("does nothing when no fields to redact", () => {
const requestJson = JSON.stringify({
email: "[email protected]",
password: "secret123",
});

const record = {
request: {
method: "POST",
path: "/login",
headers: {},
body: Buffer.from(requestJson, "utf8"),
},
response: {
status: { code: 200 },
headers: {},
body: Buffer.from("{}", "utf8"),
},
};

redactBodyFields(record, []);

const redactedRequest = JSON.parse(record.request.body.toString("utf8"));
expect(redactedRequest.email).toEqual("[email protected]");
expect(redactedRequest.password).toEqual("secret123");
});
});
74 changes: 73 additions & 1 deletion src/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class Persistence {
constructor(
private readonly tapeDir: string,
private readonly redactHeaders: string[],
private readonly redactBodyFields: string[],
) {}

/**
Expand All @@ -41,10 +42,11 @@ export class Persistence {
}

/**
* Redacts the request headers of the given record, depending on the redactHeaders array
* Redacts the request headers and body fields of the given record
*/
private redact(record: TapeRecord): TapeRecord {
redactRequestHeaders(record, this.redactHeaders);
redactBodyFields(record, this.redactBodyFields);
return record;
}

Expand Down Expand Up @@ -90,6 +92,76 @@ export function redactRequestHeaders(
});
}

/**
* Redacts JSON body fields in request and response bodies
*/
export function redactBodyFields(
record: TapeRecord,
redactFields: string[],
) {
if (redactFields.length === 0) {
return;
}

// Redact request body
record.request.body = redactBufferFields(record.request.body, redactFields);

// Redact response body
record.response.body = redactBufferFields(record.response.body, redactFields);
}

/**
* Redacts fields in a Buffer by parsing as JSON if possible
*/
function redactBufferFields(buffer: Buffer, redactFields: string[]): Buffer {
if (!buffer || buffer.length === 0) {
return buffer;
}

try {
const bodyString = buffer.toString("utf8");
const parsed = JSON.parse(bodyString);

// Recursively redact fields
redactObjectFields(parsed, redactFields);

// Convert back to buffer
return Buffer.from(JSON.stringify(parsed), "utf8");
} catch (e) {
// If not JSON or can't parse, return original buffer
return buffer;
}
}

/**
* Recursively redacts fields in an object or array
*/
function redactObjectFields(obj: any, redactFields: string[]): void {
if (typeof obj !== "object" || obj === null) {
return;
}

if (Array.isArray(obj)) {
// Handle arrays
obj.forEach((item) => redactObjectFields(item, redactFields));
} else {
// Handle objects
Object.keys(obj).forEach((key) => {
// Check if this key should be redacted (case-insensitive)
const shouldRedact = redactFields.some(
(field) => field.toLowerCase() === key.toLowerCase(),
);

if (shouldRedact) {
obj[key] = "XXXX";
} else if (typeof obj[key] === "object" && obj[key] !== null) {
// Recursively redact nested objects/arrays
redactObjectFields(obj[key], redactFields);
}
});
}
}

export function persistTape(record: TapeRecord): PersistedTapeRecord {
return {
request: {
Expand Down
Loading
Loading