Skip to content

Commit 3a7c26a

Browse files
author
E. Cooper
committed
Redact any common secrets from fetch debug logging
1 parent 334ed9f commit 3a7c26a

File tree

3 files changed

+71
-22
lines changed

3 files changed

+71
-22
lines changed

src/lib/fetch-wrapper.mjs

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { inspect } from "node:util";
44

55
import { container } from "../config/container.mjs";
6+
import { redactedStringify } from "./formatting/redact.mjs";
67

78
// this wrapper exists for only one reason: logging
89
// in the future, it could also be extended for error-handling,
@@ -24,7 +25,7 @@ export default async function fetchWrapper(url, options) {
2425
let body;
2526
if (isJSON) {
2627
body = await response.json();
27-
logMessage += ` with body:\n${JSON.stringify(body, null, 2)}`;
28+
logMessage += ` with body:\n${redactedStringify(body, null, 2)}`;
2829
}
2930

3031
logger.debug(logMessage, "fetch");

src/lib/formatting/redact.mjs

+17-7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const REDACT_FILL = 8;
2+
13
/**
24
* Redacts a string by replacing everything except the first and last four characters with asterisks.
35
* If the string is too short to display both the first and last four characters, the first four
@@ -7,26 +9,32 @@
79
* @returns {string} The redacted string.
810
*/
911
export function redact(text) {
10-
if (!text) return text;
12+
if (text === null || text === undefined) return text;
13+
14+
// If the text is not a string, we can't redact it, but instead of throwing an error,
15+
// just return a string of asterisks. We fail open because this is effectively a logging
16+
// function and we don't want to break the application.
17+
if (typeof text !== "string") {
18+
return "*".repeat(REDACT_FILL);
19+
}
1120

1221
// If the string is less than 12 characters long, it is completely replaced with asterisks.
13-
// This is so we can guarantee that the redacted string is at least 8 characters long.
22+
// This is so we can guarantee that the redacted string is at least REDACT_FILL characters long.
1423
// This aligns with minimum password lengths.
1524
if (text.length < 12) {
16-
return "*".repeat(text.length);
25+
return "*".repeat(REDACT_FILL);
1726
}
1827

1928
// If the string is less than 16, we can't redact both, so display the last four only.
2029
if (text.length < 16) {
2130
const lastFour = text.slice(-4);
22-
return `${"*".repeat(text.length - 4)}${lastFour}`;
31+
return `${"*".repeat(REDACT_FILL)}${lastFour}`;
2332
}
2433

2534
// Otherwise, redact the middle of the string and keep the first and last four characters.
2635
const firstFour = text.slice(0, 4);
2736
const lastFour = text.slice(-4);
28-
const middleLength = text.length - 8;
29-
return `${firstFour}${"*".repeat(middleLength)}${lastFour}`;
37+
return `${firstFour}${"*".repeat(REDACT_FILL)}${lastFour}`;
3038
}
3139

3240
/**
@@ -51,7 +59,9 @@ export function redactedStringify(obj, replacer, space) {
5159
.replace(/-/g, "");
5260
if (
5361
normalizedKey.includes("secret") ||
54-
normalizedKey.includes("accountkey")
62+
normalizedKey.includes("accountkey") ||
63+
normalizedKey.includes("refreshtoken") ||
64+
normalizedKey.includes("accesstoken")
5565
) {
5666
return redact(resolvedReplaced(key, value));
5767
}

test/lib/formatting/redact.mjs

+52-14
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,27 @@ describe("redact", () => {
1111
expect(redact(undefined)).to.be.undefined;
1212
});
1313

14+
it("returns a string of asterisks for non-string values", () => {
15+
expect(redact({})).to.equal("********");
16+
expect(redact([])).to.equal("********");
17+
expect(redact(123)).to.equal("********");
18+
expect(redact(true)).to.equal("********");
19+
expect(redact(false)).to.equal("********");
20+
});
21+
1422
it("completely redacts strings shorter than 12 characters", () => {
15-
expect(redact("short")).to.equal("*****");
16-
expect(redact("mediumtext")).to.equal("**********");
23+
expect(redact("short")).to.equal("********");
24+
expect(redact("mediumtext")).to.equal("********");
1725
});
1826

1927
it("keeps last 4 characters for strings between 12 and 15 characters", () => {
2028
expect(redact("123456789012")).to.equal("********9012");
21-
expect(redact("1234567890123")).to.equal("*********0123");
29+
expect(redact("1234567890123")).to.equal("********0123");
2230
});
2331

2432
it("keeps first and last 4 characters for strings 16 or more characters", () => {
2533
expect(redact("1234567890123456")).to.equal("1234********3456");
26-
expect(redact("12345678901234567")).to.equal("1234*********4567");
34+
expect(redact("12345678901234567")).to.equal("1234********4567");
2735
});
2836
});
2937

@@ -40,10 +48,10 @@ describe("redactedStringify", () => {
4048
const result = JSON.parse(redactedStringify(obj));
4149

4250
expect(result.normal).to.equal("visible");
43-
expect(result.secret).to.equal("*******");
44-
expect(result.mySecret).to.equal("*********-too");
45-
expect(result["account-key"]).to.equal("***********");
46-
expect(result.bigSecret).to.equal("this*************cret");
51+
expect(result.secret).to.equal("********");
52+
expect(result.mySecret).to.equal("********-too");
53+
expect(result["account-key"]).to.equal("********");
54+
expect(result.bigSecret).to.equal("this********cret");
4755
});
4856

4957
it("redacts keys containing 'accountkey'", () => {
@@ -55,10 +63,40 @@ describe("redactedStringify", () => {
5563
};
5664
const result = JSON.parse(redactedStringify(obj));
5765

58-
expect(result.accountkey).to.equal("******");
59-
expect(result.account_key).to.equal("*********0123");
66+
expect(result.accountkey).to.equal("********");
67+
expect(result.account_key).to.equal("********0123");
6068
expect(result.myaccountkey).to.equal("1234********3456");
61-
expect(result.longaccountkey).to.equal("test**********ey-1");
69+
expect(result.longaccountkey).to.equal("test********ey-1");
70+
});
71+
72+
it("redacts keys containing 'accesstoken'", () => {
73+
const obj = {
74+
accesstoken: "secret",
75+
access_token: "1234567890123",
76+
myaccesstoken: "1234567890123456",
77+
longaccesstoken: "test-access-token-1",
78+
};
79+
const result = JSON.parse(redactedStringify(obj));
80+
81+
expect(result.accesstoken).to.equal("********");
82+
expect(result.access_token).to.equal("********0123");
83+
expect(result.myaccesstoken).to.equal("1234********3456");
84+
expect(result.longaccesstoken).to.equal("test********en-1");
85+
});
86+
87+
it("redacts keys containing 'refreshtoken'", () => {
88+
const obj = {
89+
refreshtoken: "secret",
90+
refresh_token: "1234567890123",
91+
myrefreshtoken: "1234567890123456",
92+
longrefreshtoken: "test-refresh-token-1",
93+
};
94+
const result = JSON.parse(redactedStringify(obj));
95+
96+
expect(result.refreshtoken).to.equal("********");
97+
expect(result.refresh_token).to.equal("********0123");
98+
expect(result.myrefreshtoken).to.equal("1234********3456");
99+
expect(result.longrefreshtoken).to.equal("test********en-1");
62100
});
63101

64102
it("respects custom replacer function", () => {
@@ -72,9 +110,9 @@ describe("redactedStringify", () => {
72110

73111
const result = JSON.parse(redactedStringify(obj, replacer));
74112

75-
expect(result.secret).to.equal("*******");
113+
expect(result.secret).to.equal("********");
76114
expect(result.normal).to.equal("SHOW-ME");
77-
expect(result.longSecret).to.equal("1234************************9012");
115+
expect(result.longSecret).to.equal("1234********9012");
78116
});
79117

80118
it("respects space parameter for formatting", () => {
@@ -89,7 +127,7 @@ describe("redactedStringify", () => {
89127
expect(formatted).to.include(" ");
90128
expect(JSON.parse(formatted)).to.deep.equal({
91129
normal: "visible",
92-
secret: "*******",
130+
secret: "********",
93131
longSecret: "1234********3456",
94132
});
95133
});

0 commit comments

Comments
 (0)