diff --git a/src/commands/schema/push.mjs b/src/commands/schema/push.mjs index a56e5e62..72d6d95d 100644 --- a/src/commands/schema/push.mjs +++ b/src/commands/schema/push.mjs @@ -3,8 +3,8 @@ import path from "path"; import { container } from "../../cli.mjs"; -import { ValidationError } from "../../lib/errors.mjs"; import { yargsWithCommonQueryOptions } from "../../lib/command-helpers.mjs"; +import { ValidationError } from "../../lib/errors.mjs"; import { getSecret } from "../../lib/fauna-client.mjs"; import { reformatFSL } from "../../lib/schema.mjs"; import { localSchemaOptions } from "./schema.mjs"; diff --git a/src/lib/formatting/redact.mjs b/src/lib/formatting/redact.mjs new file mode 100644 index 00000000..3080a5f2 --- /dev/null +++ b/src/lib/formatting/redact.mjs @@ -0,0 +1,62 @@ +/** + * Redacts a string by replacing everything except the first and last four characters with asterisks. + * If the string is too short to display both the first and last four characters, the first four + * are displayed and the rest are redacted. If its less than 12 characters, the whole string is redacted. + * + * @param {string} text - The string to redact. + * @returns {string} The redacted string. + */ +export function redact(text) { + if (!text) return text; + + // If the string is less than 12 characters long, it is completely replaced with asterisks. + // This is so we can guarantee that the redacted string is at least 8 characters long. + // This aligns with minimum password lengths. + if (text.length < 12) { + return "*".repeat(text.length); + } + + // If the string is less than 16, we can't redact both, so display the last four only. + if (text.length < 16) { + const lastFour = text.slice(-4); + return `${"*".repeat(text.length - 4)}${lastFour}`; + } + + // Otherwise, redact the middle of the string and keep the first and last four characters. + const firstFour = text.slice(0, 4); + const lastFour = text.slice(-4); + const middleLength = text.length - 8; + return `${firstFour}${"*".repeat(middleLength)}${lastFour}`; +} + +/** + * Stringifies an object and redacts any keys that contain the word "secret". + * + * @param {*} obj - The object to stringify. + * @param {((key, value) => value) | null} [replacer] - A function that can be used to modify the value of each key before it is redacted. + * @param {number} [space] - The number of spaces to use for indentation. + * @returns {string} The redacted string. + */ +export function redactedStringify(obj, replacer, space) { + // If replacer is not provided, use a default function that returns the value unchanged + const resolvedReplaced = replacer ? replacer : (_key, value) => value; + + // Now we can stringify using our redact function and the resolved replacer + return JSON.stringify( + obj, + (key, value) => { + const normalizedKey = key + .toLowerCase() + .replace(/_/g, "") + .replace(/-/g, ""); + if ( + normalizedKey.includes("secret") || + normalizedKey.includes("accountkey") + ) { + return redact(resolvedReplaced(key, value)); + } + return resolvedReplaced(key, value); + }, + space, + ); +} diff --git a/src/lib/middleware.mjs b/src/lib/middleware.mjs index 9a956cd6..631ce256 100644 --- a/src/lib/middleware.mjs +++ b/src/lib/middleware.mjs @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { container } from "../cli.mjs"; import { fixPath } from "../lib/file-util.mjs"; +import { redactedStringify } from "./formatting/redact.mjs"; const LOCAL_URL = "http://localhost:8443"; const LOCAL_SECRET = "secret"; @@ -14,7 +15,7 @@ const DEFAULT_URL = "https://db.fauna.com"; export function logArgv(argv) { const logger = container.resolve("logger"); - logger.debug(JSON.stringify(argv, null, 4), "argv", argv); + logger.debug(redactedStringify(argv, null, 4), "argv", argv); logger.debug( `Existing Fauna environment variables: ${captureEnvVars()}`, "argv", @@ -23,7 +24,7 @@ export function logArgv(argv) { } function captureEnvVars() { - return JSON.stringify( + return redactedStringify( Object.entries(process.env) .filter(([key]) => key.startsWith("FAUNA_")) .reduce((acc, [key, value]) => { diff --git a/test/lib/formatting/redact.mjs b/test/lib/formatting/redact.mjs new file mode 100644 index 00000000..f2464281 --- /dev/null +++ b/test/lib/formatting/redact.mjs @@ -0,0 +1,95 @@ +import { expect } from "chai"; + +import { + redact, + redactedStringify, +} from "../../../src/lib/formatting/redact.mjs"; + +describe("redact", () => { + it("returns null/undefined values unchanged", () => { + expect(redact(null)).to.be.null; + expect(redact(undefined)).to.be.undefined; + }); + + it("completely redacts strings shorter than 12 characters", () => { + expect(redact("short")).to.equal("*****"); + expect(redact("mediumtext")).to.equal("**********"); + }); + + it("keeps last 4 characters for strings between 12 and 15 characters", () => { + expect(redact("123456789012")).to.equal("********9012"); + expect(redact("1234567890123")).to.equal("*********0123"); + }); + + it("keeps first and last 4 characters for strings 16 or more characters", () => { + expect(redact("1234567890123456")).to.equal("1234********3456"); + expect(redact("12345678901234567")).to.equal("1234*********4567"); + }); +}); + +describe("redactedStringify", () => { + it("redacts keys containing 'secret'", () => { + const obj = { + normal: "visible", + secret: "hide-me", + mySecret: "hide-this-too", + secret_key: "also-hidden", + bigSecret: "this-is-a-long-secret", + }; + const result = JSON.parse(redactedStringify(obj)); + + expect(result.normal).to.equal("visible"); + expect(result.secret).to.equal("*******"); + expect(result.mySecret).to.equal("*********-too"); + expect(result.secret_key).to.equal("***********"); + expect(result.bigSecret).to.equal("this*************cret"); + }); + + it("redacts keys containing 'accountkey'", () => { + const obj = { + accountkey: "secret", + account_key: "1234567890123", + myaccountkey: "1234567890123456", + longaccountkey: "test-account-key-1", + }; + const result = JSON.parse(redactedStringify(obj)); + + expect(result.accountkey).to.equal("******"); + expect(result.account_key).to.equal("*********0123"); + expect(result.myaccountkey).to.equal("1234********3456"); + expect(result.longaccountkey).to.equal("test**********ey-1"); + }); + + it("respects custom replacer function", () => { + const obj = { + secret: "hide-me", + normal: "show-me", + longSecret: "12345678901234567890123456789012", + }; + const replacer = (key, value) => + key === "normal" ? value.toUpperCase() : value; + + const result = JSON.parse(redactedStringify(obj, replacer)); + + expect(result.secret).to.equal("*******"); + expect(result.normal).to.equal("SHOW-ME"); + expect(result.longSecret).to.equal("1234************************9012"); + }); + + it("respects space parameter for formatting", () => { + const obj = { + normal: "visible", + secret: "hide-me", + longSecret: "1234567890123456", + }; + const formatted = redactedStringify(obj, null, 2); + + expect(formatted).to.include("\n"); + expect(formatted).to.include(" "); + expect(JSON.parse(formatted)).to.deep.equal({ + normal: "visible", + secret: "*******", + longSecret: "1234********3456", + }); + }); +});