@supabase/pg-delta 1.0.0-alpha.24 → 1.0.0-alpha.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/core/catalog.model.d.ts +2 -2
  2. package/dist/core/catalog.model.js +26 -21
  3. package/dist/core/integrations/supabase.js +84 -0
  4. package/dist/core/objects/aggregate/changes/aggregate.privilege.js +21 -9
  5. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.js +4 -1
  6. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.js +6 -3
  7. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.d.ts +11 -0
  8. package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js +11 -0
  9. package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.js +4 -1
  10. package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.js +6 -3
  11. package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.d.ts +11 -0
  12. package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.js +11 -0
  13. package/dist/core/objects/foreign-data-wrapper/sensitive-options.d.ts +32 -0
  14. package/dist/core/objects/foreign-data-wrapper/sensitive-options.js +129 -0
  15. package/dist/core/objects/foreign-data-wrapper/server/changes/server.alter.js +4 -1
  16. package/dist/core/objects/foreign-data-wrapper/server/changes/server.create.js +6 -3
  17. package/dist/core/objects/foreign-data-wrapper/server/server.model.d.ts +10 -0
  18. package/dist/core/objects/foreign-data-wrapper/server/server.model.js +10 -0
  19. package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.js +4 -1
  20. package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.js +6 -3
  21. package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.d.ts +10 -0
  22. package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.js +10 -0
  23. package/dist/core/objects/rls-policy/rls-policy.model.d.ts +2 -2
  24. package/dist/core/objects/table/table.model.js +7 -2
  25. package/package.json +1 -1
  26. package/src/core/catalog.model.ts +36 -20
  27. package/src/core/integrations/supabase.test.ts +198 -0
  28. package/src/core/integrations/supabase.ts +84 -0
  29. package/src/core/objects/aggregate/changes/aggregate.privilege.test.ts +79 -0
  30. package/src/core/objects/aggregate/changes/aggregate.privilege.ts +22 -9
  31. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.test.ts +34 -4
  32. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts +5 -1
  33. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.test.ts +34 -0
  34. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts +7 -5
  35. package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts +11 -0
  36. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.test.ts +25 -4
  37. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts +5 -1
  38. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.test.ts +54 -0
  39. package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts +7 -5
  40. package/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +11 -0
  41. package/src/core/objects/foreign-data-wrapper/sensitive-options.test.ts +98 -0
  42. package/src/core/objects/foreign-data-wrapper/sensitive-options.ts +133 -0
  43. package/src/core/objects/foreign-data-wrapper/server/changes/server.alter.test.ts +39 -4
  44. package/src/core/objects/foreign-data-wrapper/server/changes/server.alter.ts +5 -1
  45. package/src/core/objects/foreign-data-wrapper/server/changes/server.create.test.ts +36 -0
  46. package/src/core/objects/foreign-data-wrapper/server/changes/server.create.ts +7 -5
  47. package/src/core/objects/foreign-data-wrapper/server/server.model.ts +10 -0
  48. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.test.ts +39 -6
  49. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts +5 -1
  50. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.test.ts +38 -2
  51. package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts +7 -5
  52. package/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +10 -0
  53. package/src/core/objects/table/table.model.ts +7 -2
  54. package/src/core/plan/sql-format/format-off.test.ts +4 -4
  55. package/src/core/plan/sql-format/format-pretty-lower-leading.test.ts +4 -4
  56. package/src/core/plan/sql-format/format-pretty-narrow.test.ts +5 -4
  57. package/src/core/plan/sql-format/format-pretty-preserve.test.ts +4 -4
  58. package/src/core/plan/sql-format/format-pretty-upper.test.ts +4 -4
@@ -120,17 +120,47 @@ describe.concurrent("foreign-data-wrapper", () => {
120
120
  const change = new AlterForeignDataWrapperSetOptions({
121
121
  foreignDataWrapper: fdw,
122
122
  options: [
123
- { action: "ADD", option: "new_option", value: "new_value" },
124
- { action: "SET", option: "existing_option", value: "updated_value" },
125
- { action: "DROP", option: "old_option" },
123
+ { action: "ADD", option: "use_remote_estimate", value: "true" },
124
+ { action: "SET", option: "fetch_size", value: "200" },
125
+ { action: "DROP", option: "fdw_tuple_cost" },
126
126
  ],
127
127
  });
128
128
 
129
129
  await assertValidSql(change.serialize());
130
130
 
131
131
  expect(change.serialize()).toBe(
132
- "ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (ADD new_option 'new_value', SET existing_option 'updated_value', DROP old_option)",
132
+ "ALTER FOREIGN DATA WRAPPER test_fdw OPTIONS (ADD use_remote_estimate 'true', SET fetch_size '200', DROP fdw_tuple_cost)",
133
133
  );
134
134
  });
135
+
136
+ test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => {
137
+ const props: ForeignDataWrapperProps = {
138
+ name: "leaky_fdw",
139
+ owner: "postgres",
140
+ handler: null,
141
+ validator: null,
142
+ options: null,
143
+ comment: null,
144
+ privileges: [],
145
+ };
146
+ const fdw = new ForeignDataWrapper(props);
147
+ const change = new AlterForeignDataWrapperSetOptions({
148
+ foreignDataWrapper: fdw,
149
+ options: [
150
+ { action: "ADD", option: "password", value: "shared-fdw-secret" },
151
+ { action: "SET", option: "use_remote_estimate", value: "true" },
152
+ { action: "ADD", option: "api_key", value: "leaked-api-key" },
153
+ ],
154
+ });
155
+
156
+ await assertValidSql(change.serialize());
157
+
158
+ const sql = change.serialize();
159
+ expect(sql).not.toContain("shared-fdw-secret");
160
+ expect(sql).not.toContain("leaked-api-key");
161
+ expect(sql).toContain("SET use_remote_estimate 'true'");
162
+ expect(sql).toContain("ADD password '__OPTION_PASSWORD__'");
163
+ expect(sql).toContain("ADD api_key '__OPTION_API_KEY__'");
164
+ });
135
165
  });
136
166
  });
@@ -1,6 +1,7 @@
1
1
  import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts";
2
2
  import { quoteLiteral } from "../../../base.change.ts";
3
3
  import { stableId } from "../../../utils.ts";
4
+ import { redactOptionValue } from "../../sensitive-options.ts";
4
5
  import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts";
5
6
  import { AlterForeignDataWrapperChange } from "./foreign-data-wrapper.base.ts";
6
7
 
@@ -87,7 +88,10 @@ export class AlterForeignDataWrapperSetOptions extends AlterForeignDataWrapperCh
87
88
  if (opt.action === "DROP") {
88
89
  optionParts.push(`DROP ${opt.option}`);
89
90
  } else {
90
- const value = opt.value !== undefined ? quoteLiteral(opt.value) : "''";
91
+ const value =
92
+ opt.value !== undefined
93
+ ? quoteLiteral(redactOptionValue(opt.option, opt.value))
94
+ : "''";
91
95
  optionParts.push(`${opt.action} ${opt.option} ${value}`);
92
96
  }
93
97
  }
@@ -157,4 +157,38 @@ describe("foreign-data-wrapper", () => {
157
157
  "CREATE FOREIGN DATA WRAPPER test_fdw HANDLER extensions.iceberg_fdw_handler VALIDATOR extensions.iceberg_fdw_validator",
158
158
  );
159
159
  });
160
+
161
+ test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => {
162
+ // FDW-level OPTIONS set defaults that flow down to every server using
163
+ // the wrapper, so a shared `password` or `api_key` here must redact.
164
+ const fdw = new ForeignDataWrapper({
165
+ name: "leaky_fdw",
166
+ owner: "postgres",
167
+ handler: null,
168
+ validator: null,
169
+ options: [
170
+ "use_remote_estimate",
171
+ "true",
172
+ "password",
173
+ "shared-fdw-secret",
174
+ "api_key",
175
+ "leaked-api-key",
176
+ ],
177
+ comment: null,
178
+ privileges: [],
179
+ });
180
+
181
+ const change = new CreateForeignDataWrapper({
182
+ foreignDataWrapper: fdw,
183
+ });
184
+
185
+ await assertValidSql(change.serialize());
186
+
187
+ const sql = change.serialize();
188
+ expect(sql).not.toContain("shared-fdw-secret");
189
+ expect(sql).not.toContain("leaked-api-key");
190
+ expect(sql).toContain("use_remote_estimate 'true'");
191
+ expect(sql).toContain("password '__OPTION_PASSWORD__'");
192
+ expect(sql).toContain("api_key '__OPTION_API_KEY__'");
193
+ });
160
194
  });
@@ -1,6 +1,7 @@
1
1
  import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts";
2
2
  import { quoteLiteral } from "../../../base.change.ts";
3
3
  import { stableId } from "../../../utils.ts";
4
+ import { redactOptionValue } from "../../sensitive-options.ts";
4
5
  import type { ForeignDataWrapper } from "../foreign-data-wrapper.model.ts";
5
6
  import { CreateForeignDataWrapperChange } from "./foreign-data-wrapper.base.ts";
6
7
 
@@ -80,11 +81,12 @@ export class CreateForeignDataWrapper extends CreateForeignDataWrapperChange {
80
81
  ) {
81
82
  const optionPairs: string[] = [];
82
83
  for (let i = 0; i < this.foreignDataWrapper.options.length; i += 2) {
83
- if (i + 1 < this.foreignDataWrapper.options.length) {
84
- optionPairs.push(
85
- `${this.foreignDataWrapper.options[i]} ${quoteLiteral(this.foreignDataWrapper.options[i + 1])}`,
86
- );
87
- }
84
+ const key = this.foreignDataWrapper.options[i];
85
+ const value = this.foreignDataWrapper.options[i + 1];
86
+ if (key === undefined || value === undefined) continue;
87
+ optionPairs.push(
88
+ `${key} ${quoteLiteral(redactOptionValue(key, value))}`,
89
+ );
88
90
  }
89
91
  if (optionPairs.length > 0) {
90
92
  parts.push(`OPTIONS (${optionPairs.join(", ")})`);
@@ -78,6 +78,17 @@ export class ForeignDataWrapper extends BasePgModel {
78
78
  }
79
79
  }
80
80
 
81
+ /**
82
+ * Extract `pg_foreign_data_wrapper` rows into `ForeignDataWrapper` models.
83
+ *
84
+ * The returned models carry option values **verbatim** from
85
+ * `pg_foreign_data_wrapper.fdwoptions`, which means a wrapper that ships
86
+ * shared credentials (`password`, `api_key`, …) would expose them
87
+ * cleartext in memory. Always route through `extractCatalog` (which
88
+ * calls `normalizeCatalog`) before emitting options to any output
89
+ * channel — see CLI-1467 and
90
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
91
+ */
81
92
  export async function extractForeignDataWrappers(
82
93
  pool: Pool,
83
94
  ): Promise<ForeignDataWrapper[]> {
@@ -324,17 +324,38 @@ describe.concurrent("foreign-table", () => {
324
324
  const change = new AlterForeignTableSetOptions({
325
325
  foreignTable,
326
326
  options: [
327
- { action: "ADD", option: "new_option", value: "new_value" },
328
- { action: "SET", option: "existing_option", value: "updated_value" },
329
- { action: "DROP", option: "old_option" },
327
+ { action: "ADD", option: "schema_name", value: "remote_schema" },
328
+ { action: "SET", option: "table_name", value: "updated_table" },
329
+ { action: "DROP", option: "column_name" },
330
330
  ],
331
331
  });
332
332
 
333
333
  await assertValidSql(change.serialize());
334
334
 
335
335
  expect(change.serialize()).toBe(
336
- "ALTER FOREIGN TABLE public.test_table OPTIONS (ADD new_option 'new_value', SET existing_option 'updated_value', DROP old_option)",
336
+ "ALTER FOREIGN TABLE public.test_table OPTIONS (ADD schema_name 'remote_schema', SET table_name 'updated_table', DROP column_name)",
337
337
  );
338
338
  });
339
+
340
+ test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => {
341
+ const foreignTable = new ForeignTable(baseTableProps);
342
+ const change = new AlterForeignTableSetOptions({
343
+ foreignTable,
344
+ options: [
345
+ { action: "ADD", option: "password", value: "table-shared-secret" },
346
+ { action: "SET", option: "schema_name", value: "remote_schema" },
347
+ { action: "ADD", option: "api_key", value: "leaked-api-key" },
348
+ ],
349
+ });
350
+
351
+ await assertValidSql(change.serialize());
352
+
353
+ const sql = change.serialize();
354
+ expect(sql).not.toContain("table-shared-secret");
355
+ expect(sql).not.toContain("leaked-api-key");
356
+ expect(sql).toContain("SET schema_name 'remote_schema'");
357
+ expect(sql).toContain("ADD password '__OPTION_PASSWORD__'");
358
+ expect(sql).toContain("ADD api_key '__OPTION_API_KEY__'");
359
+ });
339
360
  });
340
361
  });
@@ -2,6 +2,7 @@ import type { SerializeOptions } from "../../../../integrations/serialize/serial
2
2
  import { quoteLiteral } from "../../../base.change.ts";
3
3
  import type { ColumnProps } from "../../../base.model.ts";
4
4
  import { stableId } from "../../../utils.ts";
5
+ import { redactOptionValue } from "../../sensitive-options.ts";
5
6
  import type { ForeignTable } from "../foreign-table.model.ts";
6
7
  import { AlterForeignTableChange } from "./foreign-table.base.ts";
7
8
 
@@ -327,7 +328,10 @@ export class AlterForeignTableSetOptions extends AlterForeignTableChange {
327
328
  if (opt.action === "DROP") {
328
329
  optionParts.push(`DROP ${opt.option}`);
329
330
  } else {
330
- const value = opt.value !== undefined ? quoteLiteral(opt.value) : "''";
331
+ const value =
332
+ opt.value !== undefined
333
+ ? quoteLiteral(redactOptionValue(opt.option, opt.value))
334
+ : "''";
331
335
  optionParts.push(`${opt.action} ${opt.option} ${value}`);
332
336
  }
333
337
  }
@@ -207,4 +207,58 @@ describe("foreign-table", () => {
207
207
  "CREATE FOREIGN TABLE public.test_table (id integer, name text) SERVER test_server OPTIONS (schema_name 'remote_schema', table_name 'remote_table')",
208
208
  );
209
209
  });
210
+
211
+ test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => {
212
+ // Foreign tables don't usually carry credentials, but a wrapper is
213
+ // free to define one — make sure the redaction policy still applies.
214
+ const foreignTable = new ForeignTable({
215
+ schema: "public",
216
+ name: "leaky_table",
217
+ owner: "postgres",
218
+ server: "test_server",
219
+ columns: [
220
+ {
221
+ name: "id",
222
+ position: 1,
223
+ data_type: "integer",
224
+ data_type_str: "integer",
225
+ is_custom_type: false,
226
+ custom_type_type: null,
227
+ custom_type_category: null,
228
+ custom_type_schema: null,
229
+ custom_type_name: null,
230
+ not_null: false,
231
+ is_identity: false,
232
+ is_identity_always: false,
233
+ is_generated: false,
234
+ collation: null,
235
+ default: null,
236
+ comment: null,
237
+ },
238
+ ],
239
+ options: [
240
+ "schema_name",
241
+ "remote_schema",
242
+ "api_key",
243
+ "leaked-api-key",
244
+ "password",
245
+ "table-shared-secret",
246
+ ],
247
+ comment: null,
248
+ privileges: [],
249
+ });
250
+
251
+ const change = new CreateForeignTable({
252
+ foreignTable,
253
+ });
254
+
255
+ await assertValidSql(change.serialize());
256
+
257
+ const sql = change.serialize();
258
+ expect(sql).not.toContain("leaked-api-key");
259
+ expect(sql).not.toContain("table-shared-secret");
260
+ expect(sql).toContain("schema_name 'remote_schema'");
261
+ expect(sql).toContain("api_key '__OPTION_API_KEY__'");
262
+ expect(sql).toContain("password '__OPTION_PASSWORD__'");
263
+ });
210
264
  });
@@ -1,6 +1,7 @@
1
1
  import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts";
2
2
  import { quoteLiteral } from "../../../base.change.ts";
3
3
  import { stableId } from "../../../utils.ts";
4
+ import { redactOptionValue } from "../../sensitive-options.ts";
4
5
  import type { ForeignTable } from "../foreign-table.model.ts";
5
6
  import { CreateForeignTableChange } from "./foreign-table.base.ts";
6
7
 
@@ -66,11 +67,12 @@ export class CreateForeignTable extends CreateForeignTableChange {
66
67
  if (this.foreignTable.options && this.foreignTable.options.length > 0) {
67
68
  const optionPairs: string[] = [];
68
69
  for (let i = 0; i < this.foreignTable.options.length; i += 2) {
69
- if (i + 1 < this.foreignTable.options.length) {
70
- optionPairs.push(
71
- `${this.foreignTable.options[i]} ${quoteLiteral(this.foreignTable.options[i + 1])}`,
72
- );
73
- }
70
+ const key = this.foreignTable.options[i];
71
+ const value = this.foreignTable.options[i + 1];
72
+ if (key === undefined || value === undefined) continue;
73
+ optionPairs.push(
74
+ `${key} ${quoteLiteral(redactOptionValue(key, value))}`,
75
+ );
74
76
  }
75
77
  if (optionPairs.length > 0) {
76
78
  parts.push(`OPTIONS (${optionPairs.join(", ")})`);
@@ -119,6 +119,17 @@ export class ForeignTable extends BasePgModel implements TableLikeObject {
119
119
  }
120
120
  }
121
121
 
122
+ /**
123
+ * Extract `pg_foreign_table` rows into `ForeignTable` models.
124
+ *
125
+ * The returned models carry option values **verbatim** from
126
+ * `pg_foreign_table.ftoptions`, which means a wrapper that puts
127
+ * credentials at the table level (uncommon but possible) would expose
128
+ * them cleartext in memory. Always route through `extractCatalog`
129
+ * (which calls `normalizeCatalog`) before emitting options to any
130
+ * output channel — see CLI-1467 and
131
+ * `packages/pg-delta/src/core/objects/foreign-data-wrapper/sensitive-options.ts`.
132
+ */
122
133
  export async function extractForeignTables(
123
134
  pool: Pool,
124
135
  ): Promise<ForeignTable[]> {
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ redactOptionValue,
4
+ redactSensitiveOptionPairs,
5
+ } from "./sensitive-options.ts";
6
+
7
+ describe("sensitive-options", () => {
8
+ test("preserves allowlisted connection / behavior options (case-insensitive)", () => {
9
+ // One assertion per allowlist family so an accidental drop is caught
10
+ // here instead of silently turning real plans into placeholder soup.
11
+ expect(redactOptionValue("host", "prod.example.com")).toBe(
12
+ "prod.example.com",
13
+ );
14
+ expect(redactOptionValue("HOST", "prod.example.com")).toBe(
15
+ "prod.example.com",
16
+ );
17
+ expect(redactOptionValue("port", "5432")).toBe("5432");
18
+ expect(redactOptionValue("dbname", "appdb")).toBe("appdb");
19
+ expect(redactOptionValue("user", "fdw_reader")).toBe("fdw_reader");
20
+ expect(redactOptionValue("sslmode", "require")).toBe("require");
21
+ expect(redactOptionValue("fetch_size", "200")).toBe("200");
22
+ expect(redactOptionValue("schema_name", "public")).toBe("public");
23
+ expect(redactOptionValue("region", "us-east-1")).toBe("us-east-1");
24
+ });
25
+
26
+ test("redacts unknown / credential-shaped keys to the placeholder", () => {
27
+ // None of these are in the allowlist; the policy is default-redact, so
28
+ // they all collapse to `__OPTION_<KEY>__` regardless of the value.
29
+ expect(redactOptionValue("password", "supersecret")).toBe(
30
+ "__OPTION_PASSWORD__",
31
+ );
32
+ expect(redactOptionValue("PASSWORD", "supersecret")).toBe(
33
+ "__OPTION_PASSWORD__",
34
+ );
35
+ expect(redactOptionValue("passfile", "/etc/passfile")).toBe(
36
+ "__OPTION_PASSFILE__",
37
+ );
38
+ expect(redactOptionValue("sslpassword", "x")).toBe(
39
+ "__OPTION_SSLPASSWORD__",
40
+ );
41
+ expect(redactOptionValue("api_key", "x")).toBe("__OPTION_API_KEY__");
42
+ expect(redactOptionValue("aws_secret_access_key", "x")).toBe(
43
+ "__OPTION_AWS_SECRET_ACCESS_KEY__",
44
+ );
45
+ // An unrecognized FDW option key — default-redact catches it even
46
+ // though we have not enumerated this wrapper.
47
+ expect(redactOptionValue("brand_new_wrapper_token", "x")).toBe(
48
+ "__OPTION_BRAND_NEW_WRAPPER_TOKEN__",
49
+ );
50
+ });
51
+
52
+ test("matching is exact (not substring)", () => {
53
+ // `host` is allowlisted but `host_addr` is not — substring matches must
54
+ // not promote unknown keys into the allowlist.
55
+ expect(redactOptionValue("host_addr", "10.0.0.1")).toBe(
56
+ "__OPTION_HOST_ADDR__",
57
+ );
58
+ // Inverse direction: `password_validator_extension` is not in the
59
+ // allowlist, and must not be accidentally allowlisted because some
60
+ // future loose-match scheme thought "password" looked similar.
61
+ expect(
62
+ redactOptionValue("password_validator_extension", "passwordcheck"),
63
+ ).toBe("__OPTION_PASSWORD_VALIDATOR_EXTENSION__");
64
+ });
65
+
66
+ test("redactSensitiveOptionPairs preserves safe keys and redacts the rest", () => {
67
+ expect(
68
+ redactSensitiveOptionPairs([
69
+ "host",
70
+ "localhost",
71
+ "port",
72
+ "5432",
73
+ "password",
74
+ "supersecret",
75
+ "passfile",
76
+ "/etc/secrets/passfile",
77
+ "brand_new_wrapper_token",
78
+ "leaked",
79
+ ]),
80
+ ).toEqual([
81
+ "host",
82
+ "localhost",
83
+ "port",
84
+ "5432",
85
+ "password",
86
+ "__OPTION_PASSWORD__",
87
+ "passfile",
88
+ "__OPTION_PASSFILE__",
89
+ "brand_new_wrapper_token",
90
+ "__OPTION_BRAND_NEW_WRAPPER_TOKEN__",
91
+ ]);
92
+ });
93
+
94
+ test("redactSensitiveOptionPairs handles null and empty input", () => {
95
+ expect(redactSensitiveOptionPairs(null)).toBeNull();
96
+ expect(redactSensitiveOptionPairs([])).toEqual([]);
97
+ });
98
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Sensitive-option redaction for foreign-data-wrapper objects.
3
+ *
4
+ * Foreign servers (`pg_foreign_server.srvoptions`) and user mappings
5
+ * (`pg_user_mapping.umoptions`) store libpq/FDW credentials in cleartext.
6
+ * Any code path that emits these option values verbatim — plan SQL, catalog
7
+ * snapshots, declarative export, fingerprints — leaks the credentials to
8
+ * disk, stdout, CI logs, and version control.
9
+ *
10
+ * The redaction policy is **allowlist-based**: replace every option value
11
+ * with `__OPTION_<KEY>__` unless the option key appears in
12
+ * {@link SAFE_OPTION_KEYS}. Failure mode of a missing entry is "the plan
13
+ * shows the placeholder instead of the real value" — annoying, but safe;
14
+ * a denylist's failure mode was secrets leaking, which is the bug we are
15
+ * fixing (CLI-1467).
16
+ *
17
+ * Match is case-insensitive but exact — substrings do not match, so an
18
+ * option key like `password_validator_extension` will be redacted unless
19
+ * explicitly allowlisted. When a new wrapper introduces a non-credential
20
+ * key we want to surface in plans, add it here.
21
+ */
22
+
23
+ const SAFE_OPTION_KEYS = new Set<string>([
24
+ // libpq connection params (non-credential subset).
25
+ // https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS
26
+ "host",
27
+ "hostaddr",
28
+ "port",
29
+ "dbname",
30
+ "user",
31
+ "sslmode",
32
+ "sslcompression",
33
+ "sslcert",
34
+ "sslkey",
35
+ "sslrootcert",
36
+ "sslcrl",
37
+ "sslcrldir",
38
+ "sslsni",
39
+ "requirepeer",
40
+ "krbsrvname",
41
+ "gsslib",
42
+ "sspi",
43
+ "gssencmode",
44
+ "gssdelegation",
45
+ "channel_binding",
46
+ "target_session_attrs",
47
+ "application_name",
48
+ "fallback_application_name",
49
+ "connect_timeout",
50
+ "client_encoding",
51
+ "options",
52
+ "keepalives",
53
+ "keepalives_idle",
54
+ "keepalives_interval",
55
+ "keepalives_count",
56
+ "tcp_user_timeout",
57
+ "replication",
58
+ "load_balance_hosts",
59
+ // postgres_fdw behavior tuning.
60
+ // https://www.postgresql.org/docs/current/postgres-fdw.html#POSTGRES-FDW-OPTIONS-CONNECTION
61
+ "use_remote_estimate",
62
+ "fdw_startup_cost",
63
+ "fdw_tuple_cost",
64
+ "fetch_size",
65
+ "batch_size",
66
+ "async_capable",
67
+ "analyze_sampling",
68
+ "parallel_commit",
69
+ "parallel_abort",
70
+ "extensions",
71
+ "updatable",
72
+ "truncatable",
73
+ "schema_name",
74
+ "table_name",
75
+ "column_name",
76
+ // Common shape for table-like FDWs (file_fdw, cloud-storage wrappers).
77
+ "schema",
78
+ "database",
79
+ "table",
80
+ "format",
81
+ "header",
82
+ "delimiter",
83
+ "quote",
84
+ "escape",
85
+ "encoding",
86
+ "compression",
87
+ // Cloud / Supabase Wrappers non-credential shape.
88
+ // https://github.com/supabase/wrappers
89
+ "region",
90
+ "endpoint",
91
+ "bucket",
92
+ "prefix",
93
+ "location",
94
+ "project_id",
95
+ "dataset_id",
96
+ "dataset",
97
+ "workspace",
98
+ "organization",
99
+ "api_version",
100
+ ]);
101
+
102
+ function redactedOptionPlaceholder(key: string): string {
103
+ return `__OPTION_${key.toUpperCase()}__`;
104
+ }
105
+
106
+ export function redactOptionValue(key: string, value: string): string {
107
+ return SAFE_OPTION_KEYS.has(key.toLowerCase())
108
+ ? value
109
+ : redactedOptionPlaceholder(key);
110
+ }
111
+
112
+ /**
113
+ * Redact non-allowlisted values in a flat `[key, value, key, value, ...]`
114
+ * options array — the shape used by the {@link Server},
115
+ * {@link UserMapping}, {@link ForeignDataWrapper}, and {@link ForeignTable}
116
+ * models.
117
+ *
118
+ * Returns `null` for `null` input, and otherwise returns an array of the
119
+ * same length as the input with sensitive values replaced.
120
+ */
121
+ export function redactSensitiveOptionPairs(
122
+ options: readonly string[] | null,
123
+ ): string[] | null {
124
+ if (options === null) return null;
125
+ const result: string[] = [];
126
+ for (let i = 0; i < options.length; i += 2) {
127
+ const key = options[i];
128
+ const value = options[i + 1];
129
+ if (key === undefined || value === undefined) continue;
130
+ result.push(key, redactOptionValue(key, value));
131
+ }
132
+ return result;
133
+ }
@@ -167,17 +167,52 @@ describe.concurrent("server", () => {
167
167
  const change = new AlterServerSetOptions({
168
168
  server,
169
169
  options: [
170
- { action: "ADD", option: "new_option", value: "new_value" },
171
- { action: "SET", option: "existing_option", value: "updated_value" },
172
- { action: "DROP", option: "old_option" },
170
+ { action: "ADD", option: "host", value: "localhost" },
171
+ { action: "SET", option: "port", value: "5433" },
172
+ { action: "DROP", option: "dbname" },
173
173
  ],
174
174
  });
175
175
 
176
176
  await assertValidSql(change.serialize());
177
177
 
178
178
  expect(change.serialize()).toBe(
179
- "ALTER SERVER test_server OPTIONS (ADD new_option 'new_value', SET existing_option 'updated_value', DROP old_option)",
179
+ "ALTER SERVER test_server OPTIONS (ADD host 'localhost', SET port '5433', DROP dbname)",
180
180
  );
181
181
  });
182
+
183
+ test("redacts sensitive option values to prevent secret leakage (CLI-1467)", async () => {
184
+ const props: ServerProps = {
185
+ name: "live_risk_server",
186
+ owner: "postgres",
187
+ foreign_data_wrapper: "postgres_fdw",
188
+ type: null,
189
+ version: null,
190
+ options: null,
191
+ comment: null,
192
+ privileges: [],
193
+ };
194
+ const server = new Server(props);
195
+ const change = new AlterServerSetOptions({
196
+ server,
197
+ options: [
198
+ { action: "ADD", option: "password", value: "server-shared-secret" },
199
+ { action: "SET", option: "host", value: "remote.example.com" },
200
+ {
201
+ action: "ADD",
202
+ option: "passfile",
203
+ value: "/etc/secrets/passfile",
204
+ },
205
+ ],
206
+ });
207
+
208
+ await assertValidSql(change.serialize());
209
+
210
+ const sql = change.serialize();
211
+ expect(sql).not.toContain("server-shared-secret");
212
+ expect(sql).not.toContain("/etc/secrets/passfile");
213
+ expect(sql).toContain("SET host 'remote.example.com'");
214
+ expect(sql).toContain("ADD password '__OPTION_PASSWORD__'");
215
+ expect(sql).toContain("ADD passfile '__OPTION_PASSFILE__'");
216
+ });
182
217
  });
183
218
  });
@@ -1,6 +1,7 @@
1
1
  import type { SerializeOptions } from "../../../../integrations/serialize/serialize.types.ts";
2
2
  import { quoteLiteral } from "../../../base.change.ts";
3
3
  import { stableId } from "../../../utils.ts";
4
+ import { redactOptionValue } from "../../sensitive-options.ts";
4
5
  import type { Server } from "../server.model.ts";
5
6
  import { AlterServerChange } from "./server.base.ts";
6
7
 
@@ -112,7 +113,10 @@ export class AlterServerSetOptions extends AlterServerChange {
112
113
  if (opt.action === "DROP") {
113
114
  optionParts.push(`DROP ${opt.option}`);
114
115
  } else {
115
- const value = opt.value !== undefined ? quoteLiteral(opt.value) : "''";
116
+ const value =
117
+ opt.value !== undefined
118
+ ? quoteLiteral(redactOptionValue(opt.option, opt.value))
119
+ : "''";
116
120
  optionParts.push(`${opt.action} ${opt.option} ${value}`);
117
121
  }
118
122
  }