Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ Open <http://localhost:3000> for the participant landing page. Open <http://loca

## Docker start

For local Docker development, the default Compose path now builds the app, waits for PostgreSQL, runs Prisma migrations, seeds demo data, and starts the web container:
For local Docker development, the default Compose path now builds the app, waits for PostgreSQL, applies Prisma migrations with `pnpm db:deploy` for the non-interactive Compose environment, seeds demo data, and starts the web container:

```bash
cp .env.example .env
docker compose up --build
```

If you only start PostgreSQL through Docker and run the app on your host, use the explicit local test path:
If you only start PostgreSQL through Docker and run the app on your host, use the explicit local development path with `pnpm db:migrate`:

```bash
docker compose up -d postgres
Expand Down
37 changes: 36 additions & 1 deletion apps/web/lib/services/researchExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,18 @@ const serverFixture = () => ({
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,
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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");
});
});
31 changes: 29 additions & 2 deletions apps/web/lib/services/researchExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,34 @@ const jsonObject = (value: Prisma.JsonValue): Record<string, unknown> =>
: {};


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)) {
Expand All @@ -33,7 +60,7 @@ const sanitizeForExport = (value: unknown): unknown => {
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([key]) => !SENSITIVE_CONFIG_KEY_PATTERN.test(key))
.filter(([key]) => !isSensitiveConfigKey(key))
.map(([key, entry]) => [key, sanitizeForExport(entry)]),
);
}
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ services:
depends_on:
postgres:
condition: service_healthy
command: pnpm db:migrate
command: pnpm db:deploy

seed-demo:
build:
Expand Down
Loading