diff --git a/README.md b/README.md index 0e968df..ad81ba3 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,14 @@ Open for the participant landing page. Open ({ mapHeight: 1, adminEmail: "researcher@example.org", adminPassword: "super-secret", + password: "exact-password-value", + apiSecret: "api-secret-123", + secret: "exact-secret-value", + auth: "basic-auth-value", allowedIpAddress: "203.0.113.9", + ipAddress: "203.0.113.10", authToken: "token-123", + token: "exact-token-value", + description: "Safe config description", + publicGoodMultiplier: 1.5, + multiplier: 2, + participationLabel: "Village cohort", }, createdAt: date, updatedAt: date, @@ -153,7 +163,11 @@ const serverFixture = () => ({ serverConfigs: [ { key: "runtime", - value: { formalFixedFee: 2, password: "hidden", nested: { ip: "198.51.100.4" } }, + value: { + formalFixedFee: 2, + password: "hidden", + nested: { ip: "198.51.100.4", description: "Nested safe description" }, + }, createdAt: date, updatedAt: date, }, @@ -191,8 +205,29 @@ describe("research export zip", () => { expect(exportedText).not.toContain("researcher@example.org"); expect(exportedText).not.toContain("super-secret"); + expect(exportedText).not.toContain("exact-password-value"); expect(exportedText).not.toContain("203.0.113.9"); + expect(exportedText).not.toContain("203.0.113.10"); expect(exportedText).not.toContain("198.51.100.4"); expect(exportedText).not.toContain("token-123"); + expect(exportedText).not.toContain("exact-token-value"); + expect(exportedText).not.toContain("api-secret-123"); + expect(exportedText).not.toContain("exact-secret-value"); + expect(exportedText).not.toContain("basic-auth-value"); + }); + + it("preserves safe config keys that contain sensitive key substrings", async () => { + prismaMock.server.findMany.mockResolvedValue([serverFixture()]); + + const zip = await buildResearchExportZip({ type: "server", serverId: "server-1" }); + const exportedText = Object.values(unzipStoredFiles(zip)).join("\n"); + + expect(exportedText).toContain("description"); + expect(exportedText).toContain("Safe config description"); + expect(exportedText).toContain("Nested safe description"); + expect(exportedText).toContain("publicGoodMultiplier"); + expect(exportedText).toContain("multiplier"); + expect(exportedText).toContain("participationLabel"); + expect(exportedText).toContain("Village cohort"); }); }); diff --git a/apps/web/lib/services/researchExport.ts b/apps/web/lib/services/researchExport.ts index ac7f4fa..24bb741 100644 --- a/apps/web/lib/services/researchExport.ts +++ b/apps/web/lib/services/researchExport.ts @@ -23,7 +23,34 @@ const jsonObject = (value: Prisma.JsonValue): Record => : {}; -const SENSITIVE_CONFIG_KEY_PATTERN = /(email|password|secret|token|credential|authorization|auth|ipAddress|ip_address|ip)/i; +const SENSITIVE_CONFIG_KEY_TERMS = new Set([ + "auth", + "authorization", + "credential", + "credentials", + "email", + "ip", + "password", + "secret", + "token", +]); +const SENSITIVE_CONFIG_KEY_ALIASES = new Set(["ipaddress", "ip_address"]); + +const configKeyTerms = (key: string): string[] => + key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[^A-Za-z0-9]+/) + .map((term) => term.toLowerCase()) + .filter(Boolean); + +const isSensitiveConfigKey = (key: string): boolean => { + const normalizedKey = key.toLowerCase(); + return ( + SENSITIVE_CONFIG_KEY_ALIASES.has(normalizedKey) || + configKeyTerms(key).some((term) => SENSITIVE_CONFIG_KEY_TERMS.has(term)) + ); +}; const sanitizeForExport = (value: unknown): unknown => { if (Array.isArray(value)) { @@ -33,7 +60,7 @@ const sanitizeForExport = (value: unknown): unknown => { if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record) - .filter(([key]) => !SENSITIVE_CONFIG_KEY_PATTERN.test(key)) + .filter(([key]) => !isSensitiveConfigKey(key)) .map(([key, entry]) => [key, sanitizeForExport(entry)]), ); } diff --git a/docker-compose.yml b/docker-compose.yml index 1e8d7ee..9e4bc48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: depends_on: postgres: condition: service_healthy - command: pnpm db:migrate + command: pnpm db:deploy seed-demo: build: