Skip to content

Commit

Permalink
Redact secrets in logArgv (#511)
Browse files Browse the repository at this point in the history
* Redact secrets in logArgv

* Display first and last 4 if the secret meets a minimum length
  • Loading branch information
ecooper authored Dec 12, 2024
1 parent 531a17d commit 2071e2e
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 3 deletions.
2 changes: 1 addition & 1 deletion src/commands/schema/push.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
62 changes: 62 additions & 0 deletions src/lib/formatting/redact.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
}
5 changes: 3 additions & 2 deletions src/lib/middleware.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ 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";
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",
Expand All @@ -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]) => {
Expand Down
95 changes: 95 additions & 0 deletions test/lib/formatting/redact.mjs
Original file line number Diff line number Diff line change
@@ -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",
});
});
});

0 comments on commit 2071e2e

Please sign in to comment.