@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.
- package/dist/core/catalog.model.d.ts +2 -2
- package/dist/core/catalog.model.js +26 -21
- package/dist/core/integrations/supabase.js +84 -0
- package/dist/core/objects/aggregate/changes/aggregate.privilege.js +21 -9
- package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.js +4 -1
- package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.js +6 -3
- package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.d.ts +11 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.js +11 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.js +4 -1
- package/dist/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.js +6 -3
- package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.d.ts +11 -0
- package/dist/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.js +11 -0
- package/dist/core/objects/foreign-data-wrapper/sensitive-options.d.ts +32 -0
- package/dist/core/objects/foreign-data-wrapper/sensitive-options.js +129 -0
- package/dist/core/objects/foreign-data-wrapper/server/changes/server.alter.js +4 -1
- package/dist/core/objects/foreign-data-wrapper/server/changes/server.create.js +6 -3
- package/dist/core/objects/foreign-data-wrapper/server/server.model.d.ts +10 -0
- package/dist/core/objects/foreign-data-wrapper/server/server.model.js +10 -0
- package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.js +4 -1
- package/dist/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.js +6 -3
- package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.d.ts +10 -0
- package/dist/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.js +10 -0
- package/dist/core/objects/rls-policy/rls-policy.model.d.ts +2 -2
- package/dist/core/objects/table/table.model.js +7 -2
- package/package.json +1 -1
- package/src/core/catalog.model.ts +36 -20
- package/src/core/integrations/supabase.test.ts +198 -0
- package/src/core/integrations/supabase.ts +84 -0
- package/src/core/objects/aggregate/changes/aggregate.privilege.test.ts +79 -0
- package/src/core/objects/aggregate/changes/aggregate.privilege.ts +22 -9
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.test.ts +34 -4
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.alter.ts +5 -1
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.test.ts +34 -0
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/changes/foreign-data-wrapper.create.ts +7 -5
- package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts +11 -0
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.test.ts +25 -4
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.ts +5 -1
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.test.ts +54 -0
- package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.ts +7 -5
- package/src/core/objects/foreign-data-wrapper/foreign-table/foreign-table.model.ts +11 -0
- package/src/core/objects/foreign-data-wrapper/sensitive-options.test.ts +98 -0
- package/src/core/objects/foreign-data-wrapper/sensitive-options.ts +133 -0
- package/src/core/objects/foreign-data-wrapper/server/changes/server.alter.test.ts +39 -4
- package/src/core/objects/foreign-data-wrapper/server/changes/server.alter.ts +5 -1
- package/src/core/objects/foreign-data-wrapper/server/changes/server.create.test.ts +36 -0
- package/src/core/objects/foreign-data-wrapper/server/changes/server.create.ts +7 -5
- package/src/core/objects/foreign-data-wrapper/server/server.model.ts +10 -0
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.test.ts +39 -6
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.alter.ts +5 -1
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.test.ts +38 -2
- package/src/core/objects/foreign-data-wrapper/user-mapping/changes/user-mapping.create.ts +7 -5
- package/src/core/objects/foreign-data-wrapper/user-mapping/user-mapping.model.ts +10 -0
- package/src/core/objects/table/table.model.ts +7 -2
- package/src/core/plan/sql-format/format-off.test.ts +4 -4
- package/src/core/plan/sql-format/format-pretty-lower-leading.test.ts +4 -4
- package/src/core/plan/sql-format/format-pretty-narrow.test.ts +5 -4
- package/src/core/plan/sql-format/format-pretty-preserve.test.ts +4 -4
- 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: "
|
|
124
|
-
{ action: "SET", option: "
|
|
125
|
-
{ action: "DROP", 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
|
|
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 =
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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(", ")})`);
|
package/src/core/objects/foreign-data-wrapper/foreign-data-wrapper/foreign-data-wrapper.model.ts
CHANGED
|
@@ -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[]> {
|
package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.alter.test.ts
CHANGED
|
@@ -324,17 +324,38 @@ describe.concurrent("foreign-table", () => {
|
|
|
324
324
|
const change = new AlterForeignTableSetOptions({
|
|
325
325
|
foreignTable,
|
|
326
326
|
options: [
|
|
327
|
-
{ action: "ADD", option: "
|
|
328
|
-
{ action: "SET", option: "
|
|
329
|
-
{ action: "DROP", 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
|
|
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 =
|
|
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
|
}
|
package/src/core/objects/foreign-data-wrapper/foreign-table/changes/foreign-table.create.test.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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: "
|
|
171
|
-
{ action: "SET", option: "
|
|
172
|
-
{ action: "DROP", 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
|
|
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 =
|
|
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
|
}
|