Skip to content
Merged
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
17 changes: 17 additions & 0 deletions apps/web/lib/api-logs/capture-request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { WorkspaceWithUsers } from "@/lib/types";
import { TokenCacheItem } from "../auth/token-cache";
import { Session } from "../auth/utils";
import { HTTP_MUTATION_METHODS, ROUTE_PATTERNS } from "./constants";
import {
maskSensitiveFields,
SENSITIVE_RESPONSE_FIELDS_BY_ROUTE,
} from "./mask-sensitive-fields";
import { recordApiLog } from "./record-api-log";

// Precompile route patterns into regexes at module load
Expand Down Expand Up @@ -79,6 +83,19 @@ export async function captureRequestLog({
responseBody = await responseClone.json();
} catch {}

// Mask sensitive fields in the response body
if (responseBody) {
const sensitiveResponseFields =
SENSITIVE_RESPONSE_FIELDS_BY_ROUTE[routePattern];

if (sensitiveResponseFields) {
responseBody = maskSensitiveFields({
body: responseBody,
keys: sensitiveResponseFields,
});
}
}

return await recordApiLog({
workspaceId: workspace.id,
method: req.method,
Expand Down
71 changes: 71 additions & 0 deletions apps/web/lib/api-logs/mask-sensitive-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const FULL_MASK = "***";
const MIDDLE_MASK = "*****";
const MIN_HIDDEN_CHARS = 4;
const LAST_VISIBLE_CHARS = 6;

// Map of route pattern -> response body fields that should be masked before logging.
export const SENSITIVE_RESPONSE_FIELDS_BY_ROUTE = {
"/tokens/embed/referrals": ["publicToken"],
} as const;

// Stripe-style partial mask: visible prefix through the last `_` (e.g. `sk_live_`),
// a fixed middle mask, and the last 6 characters (e.g. `sk_live_*****xyz123`).
// Values without enough characters between prefix and suffix fully mask.
export function maskSensitiveValue(value: unknown): string {
if (typeof value !== "string" || value.length === 0) {
return FULL_MASK;
}

const suffixLen = Math.min(LAST_VISIBLE_CHARS, value.length);
const suffixStart = value.length - suffixLen;

const lastUnderscore = value.lastIndexOf("_");
const prefixEnd = lastUnderscore >= 0 ? lastUnderscore + 1 : 0;

if (prefixEnd > suffixStart) {
return FULL_MASK;
}

if (suffixStart - prefixEnd < MIN_HIDDEN_CHARS) {
return FULL_MASK;
}

const prefix = value.slice(0, prefixEnd);
const suffix = value.slice(suffixStart);

return `${prefix}${MIDDLE_MASK}${suffix}`;
}

// Recursively mask the given keys in an object/array. Returns a new value and
// does not mutate the input. Non-object values are returned as-is.
export function maskSensitiveFields<T>({
body,
keys,
}: {
body: T;
keys: string[];
}): T {
if (!body || keys.length === 0) {
return body;
}

const keySet = new Set(keys);

const mask = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(mask);
}

if (value && typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
result[k] = keySet.has(k) ? maskSensitiveValue(v) : mask(v);
}
return result;
}

return value;
};

return mask(body) as T;
}
3 changes: 2 additions & 1 deletion apps/web/lib/integrations/hubspot/track-lead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ export const trackHubSpotLeadEvent = async ({
}

if (
contactInfo.properties.lifecyclestage !== settings.leadLifecycleStageId
contactInfo.properties.lifecyclestage?.toLowerCase() !==
settings.leadLifecycleStageId?.toLowerCase()
) {
return `Unknown contact lifecyclestage ${contactInfo.properties.lifecyclestage}. Expected ${settings.leadLifecycleStageId}.`;
}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/lib/integrations/hubspot/track-sale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export const trackHubSpotSaleEvent = async ({
return `Unknown propertyName ${propertyName}. Expected dealstage.`;
}

if (propertyValue !== settings.closedWonDealStageId) {
if (
propertyValue.toLowerCase() !== settings.closedWonDealStageId?.toLowerCase()
) {
return `Unknown propertyValue ${propertyValue}. Expected ${settings.closedWonDealStageId}.`;
}

Expand Down