kryptheon-night 0.1.0

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/tamper.js ADDED
@@ -0,0 +1,208 @@
1
+ // Can a stranger change your data?
2
+ //
3
+ // Everything before this asks whether the wrong person can READ. This asks
4
+ // whether they can WRITE, and the answer matters more: a leak is bad, but a
5
+ // stranger who can delete your customers table has taken something you cannot
6
+ // get back.
7
+ //
8
+ // It is the commonest disaster in a no-code backend, and the reason is that
9
+ // nothing looks wrong. Supabase grants anon INSERT, UPDATE and DELETE on the
10
+ // public schema by default. Row level security is what takes those back, and
11
+ // on a table where nobody ever switched it on, a logged-out stranger can empty
12
+ // the table with one request while the dashboard shows nothing at all.
13
+ //
14
+ // EVERYTHING HERE IS ROLLED BACK. Every write runs inside a transaction that
15
+ // is always rolled back, measured afterwards to confirm the table came out as
16
+ // it went in. It runs against the copy, and the copy is thrown away - but the
17
+ // rollback is what makes it safe to reason about at all.
18
+ //
19
+ // WHAT WAS MEASURED FIRST (probe-write.js, against a real database)
20
+ //
21
+ // - no row level security + the default grants: a stranger can insert,
22
+ // rewrite and delete. Confirmed, all three.
23
+ // - a SELECT policy and nothing else: writes are refused. A read rule is
24
+ // genuinely not a write rule, so an app like this must not be reported.
25
+ // - FOR ALL USING (true): a stranger can do all three.
26
+ // - an owner policy: a signed-in customer cannot touch another customer's
27
+ // rows, and Postgres reuses USING as the check for UPDATE, so a row cannot
28
+ // be pushed out of its owner's reach either.
29
+ //
30
+ // A statement that returns without error having changed zero rows is a
31
+ // refusal, not a success - row level security filters rows away rather than
32
+ // complaining. The first version of the probe got that wrong, so every verdict
33
+ // here is taken from the number of rows that actually moved.
34
+
35
+ const { quote } = require('./schema.js');
36
+ const attack = require('./attack.js');
37
+
38
+ /** Who is doing the asking, and what a finding should call them. */
39
+ const ACTORS = [
40
+ { who: 'anyone', role: 'anon', identity: null },
41
+ { who: 'any customer', role: 'authenticated', identity: attack.USER_B },
42
+ ];
43
+
44
+ /**
45
+ * Runs one write the way a request runs it, and never keeps the result.
46
+ *
47
+ * The rollback is not a tidy-up, it is the safety property. Nothing this
48
+ * attack does survives the statement that did it.
49
+ */
50
+ async function tryWrite(client, role, identity, statement, values) {
51
+ await client.query('BEGIN');
52
+ try {
53
+ await client.query("SET LOCAL statement_timeout = '15s'");
54
+ await client.query('SET LOCAL role TO ' + role);
55
+ await client.query('SELECT set_config($1, $2, true)', [
56
+ 'request.jwt.claims',
57
+ JSON.stringify(identity ? { sub: identity, role: role } : { role: role }),
58
+ ]);
59
+ const result = await client.query(statement, values || []);
60
+ return { ok: true, count: result.rowCount };
61
+ } catch (err) {
62
+ return { ok: false, count: 0, why: err.message };
63
+ } finally {
64
+ await client.query('ROLLBACK').catch(() => {});
65
+ }
66
+ }
67
+
68
+ /**
69
+ * What a write actually achieved.
70
+ *
71
+ * Three outcomes, and the middle one is the one that matters: a statement can
72
+ * succeed and change nothing, which is row level security doing its job.
73
+ */
74
+ function whatHappened(result) {
75
+ if (result.ok) return result.count > 0 ? 'got through' : 'refused';
76
+ // A WITH CHECK turning a write away IS the app defending itself, and it is
77
+ // the single most common way a correct app says no. Reading it as "I could
78
+ // not tell" put a warning on every table that had got it right, and a
79
+ // warning nobody earned is how a report stops being read.
80
+ if (/violates row-level security policy/i.test(String(result.why))) return 'refused';
81
+ return attack.refusalMeans(result.why) === 'unreachable' ? 'refused' : 'untested';
82
+ }
83
+
84
+ /**
85
+ * Every way in, per table, per kind of caller.
86
+ *
87
+ * `seeded` carries the shape of row that worked when the table was seeded, so
88
+ * the insert here is not rejected by some CHECK the seeder already solved.
89
+ */
90
+ async function tamper(client, schema, tables, seeded, policies) {
91
+ const findings = [];
92
+ const completed = [];
93
+ const blocked = [];
94
+ const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
95
+
96
+ // Which rules each table actually has, so the report can say why a write got
97
+ // through instead of assuming. Without this the report told somebody their
98
+ // rule "covers every command rather than only reading" about a policy
99
+ // written FOR INSERT - the finding was true and the reason was invented,
100
+ // which is the one thing a report cannot do twice.
101
+ //
102
+ // Permissive only. A restrictive policy can narrow what is allowed and
103
+ // never open it, so it is never the reason a write succeeded.
104
+ const rulesFor = new Map();
105
+ for (const policy of policies || []) {
106
+ if (String(policy.permissive || 'PERMISSIVE').toUpperCase() !== 'PERMISSIVE') continue;
107
+ const list = rulesFor.get(policy.table_name) || [];
108
+ const cmd = String(policy.cmd || 'ALL').toUpperCase();
109
+ if (!list.includes(cmd)) list.push(cmd);
110
+ rulesFor.set(policy.table_name, list);
111
+ }
112
+
113
+ for (const table of tables) {
114
+ if (!shapeFor.has(table.name)) continue; // never seeded, so nothing to protect
115
+ const owner = attack.ownerColumn(table);
116
+
117
+ for (const actor of ACTORS) {
118
+ const key = 'writable:' + table.name + ':' + actor.who;
119
+ const can = [];
120
+ const changed = {};
121
+ let stuck = null;
122
+
123
+ // Rows belonging to the other fake person. Scoped on purpose: a signed-in
124
+ // customer deleting their OWN rows is not a finding, it is the feature,
125
+ // and an unscoped DELETE would report every correctly built app.
126
+ const theirRows = owner
127
+ ? ' WHERE ' + quote(owner) + ' = ' + "'" + attack.USER_A + "'"
128
+ : '';
129
+ const at = quote(schema) + '.' + quote(table.name);
130
+
131
+ // Written under a name nobody has used, which is both what makes the
132
+ // row land at all and what makes it the right test: adding a row of
133
+ // your own is the feature, adding one under somebody else name is not.
134
+ const row = await attack.rowFor(client, schema, table, attack.USER_C, 7, null, shapeFor.get(table.name));
135
+ const placeholders = row.values.map((_, i) => '$' + (i + 1));
136
+ const attempts = [
137
+ row.columns.length
138
+ ? {
139
+ what: 'add',
140
+ statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
141
+ placeholders.join(', ') + ')',
142
+ values: row.values,
143
+ }
144
+ : { what: 'add', statement: 'INSERT INTO ' + at + ' DEFAULT VALUES', values: [] },
145
+ { what: 'change', statement: 'UPDATE ' + at + ' SET ' + quote(firstWritable(table)) + ' = ' +
146
+ quote(firstWritable(table)) + theirRows, values: [] },
147
+ { what: 'delete', statement: 'DELETE FROM ' + at + theirRows, values: [] },
148
+ ];
149
+
150
+ for (const move of attempts) {
151
+ if (!move.statement) continue;
152
+ const result = await tryWrite(client, actor.role, actor.identity, move.statement, move.values);
153
+ const outcome = whatHappened(result);
154
+ if (outcome === 'got through') {
155
+ can.push(move.what);
156
+ changed[move.what] = result.count;
157
+ } else if (outcome === 'untested') {
158
+ stuck = result.why;
159
+ }
160
+ }
161
+
162
+ if (stuck) {
163
+ // Something went wrong that was not the app defending itself, so no
164
+ // verdict exists for this table and saying nothing would read as safe.
165
+ blocked.push({ table: table.name, key: key, why: 'as ' + actor.who + ': ' + stuck });
166
+ continue;
167
+ }
168
+
169
+ completed.push(key);
170
+ if (can.length) {
171
+ findings.push({
172
+ kind: 'writable',
173
+ table: table.name,
174
+ who: actor.who,
175
+ can: can,
176
+ changed: changed,
177
+ owner: owner,
178
+ columns: (table.columns || []).map((c) => c.name),
179
+ rlsEnabled: table.rlsEnabled,
180
+ rules: rulesFor.get(table.name) || [],
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ return { findings: findings, completed: completed, blocked: blocked };
187
+ }
188
+
189
+ /**
190
+ * A column an UPDATE can harmlessly set to itself.
191
+ *
192
+ * Setting a column to its own value changes nothing about the row while still
193
+ * proving the write was allowed - so the finding is real and the data is not
194
+ * even momentarily wrong inside the transaction that gets rolled back.
195
+ */
196
+ function firstWritable(table) {
197
+ const usable = (table.columns || []).filter((column) => !column.generated && !column.identity);
198
+ const plain = usable.find((column) => !/^(id)$/i.test(column.name));
199
+ return (plain || usable[0] || { name: 'id' }).name;
200
+ }
201
+
202
+ module.exports = {
203
+ ACTORS: ACTORS,
204
+ tryWrite: tryWrite,
205
+ whatHappened: whatHappened,
206
+ firstWritable: firstWritable,
207
+ tamper: tamper,
208
+ };
package/trouble.js ADDED
@@ -0,0 +1,377 @@
1
+ // Everything that can go wrong before a single attack runs, said in words a
2
+ // person who has never opened a terminal can act on.
3
+ //
4
+ // The person this is for clicked "Deploy" in Lovable and has a database
5
+ // because Supabase gave them one. They did not choose Postgres, they do not
6
+ // know what a connection string is, and they will never read a stack trace.
7
+ // A message like
8
+ //
9
+ // Error: getaddrinfo ENOTFOUND db.abcdefgh.supabase.co
10
+ // at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:120:26)
11
+ //
12
+ // tells them only that the tool is not for them. Every branch below turns one
13
+ // of those into a sentence that names what is wrong and what to do next.
14
+ //
15
+ // Two rules hold this file together:
16
+ //
17
+ // 1. Never guess out loud. If the cause cannot be told apart from another
18
+ // cause, say both, rather than picking the likelier one confidently.
19
+ // 2. Never print the connection string back. It carries the password to
20
+ // the whole database, and a person pasting a report into a chat window
21
+ // would paste the credential with it.
22
+
23
+ /** The bits of a connection string that are safe to show somebody. */
24
+ function withoutSecret(text) {
25
+ // The password lives between the first colon after the scheme and the @.
26
+ // Anything else in the string - host, port, database, user - is not a
27
+ // secret and is exactly what the person needs to see to spot their typo.
28
+ try {
29
+ const url = new URL(text);
30
+ const user = url.username ? url.username + '@' : '';
31
+ return url.protocol + '//' + user + url.host + url.pathname;
32
+ } catch (err) {
33
+ return '(unreadable)';
34
+ }
35
+ }
36
+
37
+ // The placeholder Supabase puts in the string it shows you. Copying the line
38
+ // without replacing this is the single most common first mistake, and the
39
+ // error Postgres gives back for it is "password authentication failed",
40
+ // which sends people off changing their password instead of reading the line
41
+ // they pasted.
42
+ const PLACEHOLDERS = [
43
+ '[YOUR-PASSWORD]',
44
+ '[YOUR_PASSWORD]',
45
+ '[PASSWORD]',
46
+ '[DB-PASSWORD]',
47
+ 'YOUR-PASSWORD',
48
+ '[your-password]',
49
+ ];
50
+
51
+ /**
52
+ * Is this a connection string at all?
53
+ *
54
+ * Checked before connecting, because the failures here are the ones a person
55
+ * can fix in five seconds if told plainly, and the ones Postgres describes
56
+ * worst if they are allowed to reach it.
57
+ *
58
+ * Returns null when the string is usable, or the lines to print when it is
59
+ * not.
60
+ */
61
+ function readConnectionString(raw) {
62
+ const text = String(raw == null ? '' : raw).trim();
63
+
64
+ if (!text) {
65
+ return [
66
+ 'I did not get a connection string.',
67
+ '',
68
+ 'It is the line Supabase shows under Project Settings -> Database ->',
69
+ 'Connection string. It starts with postgresql://',
70
+ ];
71
+ }
72
+
73
+ // A wrapping pair of quotes survives a copy out of a code block and makes
74
+ // the URL unparseable for a reason nobody would ever guess from the error.
75
+ const unquoted = text.replace(/^["']|["']$/g, '');
76
+
77
+ for (const placeholder of PLACEHOLDERS) {
78
+ if (unquoted.includes(placeholder)) {
79
+ return [
80
+ 'That string still has ' + placeholder + ' in it.',
81
+ '',
82
+ 'Supabase shows you the line with a blank left in it for your database',
83
+ 'password. Replace ' + placeholder + ' - square brackets and all - with',
84
+ 'the password, then paste it again.',
85
+ '',
86
+ 'If you do not know the password: Supabase dashboard -> Project Settings',
87
+ '-> Database -> Database password -> Reset database password. Resetting it',
88
+ 'will break anything already using the old one, so check first.',
89
+ ];
90
+ }
91
+ }
92
+
93
+ if (/^https?:\/\//i.test(unquoted)) {
94
+ return [
95
+ 'That is a web address, not a database connection string.',
96
+ '',
97
+ 'The one beginning https://...supabase.co is your project URL - the one',
98
+ 'your app uses through the Supabase library. I need the database itself.',
99
+ '',
100
+ 'Supabase dashboard -> Project Settings -> Database -> Connection string',
101
+ '-> URI. It begins postgresql:// and has a password in it.',
102
+ ];
103
+ }
104
+
105
+ // The anon key and the service role key are both JWTs, and both get pasted
106
+ // here. The service_role key is a far more dangerous thing to have in a
107
+ // clipboard than the one being asked for, so this says so.
108
+ if (/^eyJ[A-Za-z0-9_-]+\./.test(unquoted)) {
109
+ return [
110
+ 'That is one of your API keys, not a database connection string.',
111
+ '',
112
+ 'If it was the service_role key, treat it as leaked now that it has been',
113
+ 'in a terminal: Supabase dashboard -> Project Settings -> API -> Rotate.',
114
+ '',
115
+ 'What I need is under Project Settings -> Database -> Connection string',
116
+ '-> URI. It begins postgresql://',
117
+ ];
118
+ }
119
+
120
+ let url;
121
+ try {
122
+ url = new URL(unquoted);
123
+ } catch (err) {
124
+ return [
125
+ 'I could not read that as a connection string.',
126
+ '',
127
+ 'It should be one line, no spaces, beginning postgresql:// - like',
128
+ 'postgresql://postgres:PASSWORD@db.something.supabase.co:5432/postgres',
129
+ '',
130
+ 'If you copied it out of a web page, check that the whole line came with',
131
+ 'it and that it did not get broken in half.',
132
+ ];
133
+ }
134
+
135
+ if (url.protocol !== 'postgres:' && url.protocol !== 'postgresql:') {
136
+ return [
137
+ 'That does not look like a Postgres connection string.',
138
+ '',
139
+ 'It begins "' + url.protocol + '//" and I need one beginning',
140
+ '"postgresql://". Supabase dashboard -> Project Settings -> Database ->',
141
+ 'Connection string -> URI.',
142
+ ];
143
+ }
144
+
145
+ if (!url.hostname) {
146
+ return [
147
+ 'That connection string has no server name in it.',
148
+ '',
149
+ 'There should be a name between the @ and the : near the end, like',
150
+ '@db.something.supabase.co:5432. Copy the whole line again.',
151
+ ];
152
+ }
153
+
154
+ if (!url.password) {
155
+ return [
156
+ 'That connection string has no password in it.',
157
+ '',
158
+ 'The password goes between the colon and the @, like',
159
+ 'postgresql://postgres:PASSWORD@db.something.supabase.co:5432/postgres',
160
+ '',
161
+ 'Supabase leaves a blank there when it shows you the line, so it has to',
162
+ 'be filled in before it will work.',
163
+ ];
164
+ }
165
+
166
+ return null;
167
+ }
168
+
169
+ /**
170
+ * Supabase hands out three connection strings and two of them will not do.
171
+ *
172
+ * This is a warning rather than a refusal: the shapes are recognised by their
173
+ * host names, those names have changed before, and refusing to run on a
174
+ * string that would have worked is worse than saying something and trying.
175
+ */
176
+ function poolerWarning(raw) {
177
+ let url;
178
+ try {
179
+ url = new URL(String(raw).trim().replace(/^["']|["']$/g, ''));
180
+ } catch (err) {
181
+ return null;
182
+ }
183
+
184
+ // The transaction pooler hands a different backend to every statement.
185
+ // The whole attack is "become another role inside one transaction and try
186
+ // to read", which needs the statements to stay together on one connection.
187
+ if (url.port === '6543') {
188
+ return [
189
+ 'That is the transaction pooler (port 6543). It gives every statement a',
190
+ 'different connection, and these attacks have to stay on one.',
191
+ '',
192
+ 'Use the session pooler or the direct connection instead - same page in',
193
+ 'Supabase, the one whose port is 5432.',
194
+ ];
195
+ }
196
+
197
+ return null;
198
+ }
199
+
200
+ // Postgres says why it refused in a five character code. The message beside
201
+ // it is written for whoever is holding the database, not for whoever is
202
+ // holding the app, so the code is what gets read here and the message is not.
203
+ const BY_CODE = {
204
+ // password authentication failed
205
+ '28P01': [
206
+ 'The server answered, but the password was wrong.',
207
+ '',
208
+ 'Everything else in the string was right - the address found a real',
209
+ 'database. It is only the part between the colon and the @.',
210
+ '',
211
+ 'Supabase dashboard -> Project Settings -> Database -> Database password.',
212
+ 'If you never set one, reset it there and paste the new one here.',
213
+ ],
214
+ // invalid_authorization_specification - usually the user name
215
+ '28000': [
216
+ 'The server answered, but it would not let that user in.',
217
+ '',
218
+ 'Check the name between // and the colon. For Supabase it is "postgres"',
219
+ 'on a direct connection, and "postgres.something" on a pooled one - the',
220
+ 'two are not interchangeable.',
221
+ ],
222
+ // invalid_catalog_name
223
+ '3D000': [
224
+ 'The server answered, but there is no database by that name on it.',
225
+ '',
226
+ 'That is the word after the last slash. On Supabase it is "postgres" -',
227
+ 'not the name of your project.',
228
+ ],
229
+ // insufficient_privilege
230
+ '42501': [
231
+ 'I connected, but this user is not allowed to do what I need.',
232
+ '',
233
+ 'I have to create one temporary schema, copy the shape of your tables',
234
+ 'into it, and delete it again. This user cannot create a schema.',
235
+ '',
236
+ 'Use the connection string from Project Settings -> Database rather than',
237
+ 'one you made yourself - that one is the owner and can.',
238
+ ],
239
+ // too_many_connections
240
+ '53300': [
241
+ 'Your database is already at its connection limit, so it turned me away.',
242
+ '',
243
+ 'This is about how busy the database is, not about anything being wrong',
244
+ 'with it. Close anything else that is connected - a SQL editor tab, a',
245
+ 'running app - and try again in a minute.',
246
+ ],
247
+ // cannot_connect_now - the server is starting up
248
+ '57P03': [
249
+ 'The database is awake but not ready yet.',
250
+ '',
251
+ 'Free Supabase projects go to sleep when nobody uses them, and take a few',
252
+ 'seconds to come back. Try again in half a minute.',
253
+ ],
254
+ };
255
+
256
+ // Node's own failures, which happen before Postgres has said anything at all.
257
+ const BY_SYSCALL = {
258
+ ENOTFOUND: [
259
+ 'I could not find that server.',
260
+ '',
261
+ 'Either there is a typo in the address, or this computer is not online.',
262
+ '',
263
+ 'The address is the part between the @ and the : near the end. Compare it',
264
+ 'with the one in Supabase -> Project Settings -> Database. If your project',
265
+ 'was paused or deleted, its address stops existing too.',
266
+ ],
267
+ ECONNREFUSED: [
268
+ 'That server is there, but nothing is listening on that port.',
269
+ '',
270
+ 'The port is the number after the last colon, before the slash. Supabase',
271
+ 'uses 5432 for a direct or session connection.',
272
+ ],
273
+ ETIMEDOUT: [
274
+ 'I reached the network but the database never answered.',
275
+ '',
276
+ 'This is almost always a firewall between you and it - office wifi, a',
277
+ 'company laptop, or a VPN. Databases talk on port 5432 and many networks',
278
+ 'block it outright.',
279
+ '',
280
+ 'Try again on a home connection or a phone hotspot. Nothing is wrong with',
281
+ 'your app.',
282
+ ],
283
+ EHOSTUNREACH: [
284
+ 'This computer has no route to that server.',
285
+ '',
286
+ 'Supabase direct connections answer on IPv6 only, and a lot of home and',
287
+ 'office networks have no IPv6 at all. The session pooler answers on IPv4.',
288
+ '',
289
+ 'Supabase dashboard -> Project Settings -> Database -> Connection string,',
290
+ 'and take the "Session pooler" one instead.',
291
+ ],
292
+ ENETUNREACH: [
293
+ 'This computer has no route to that server.',
294
+ '',
295
+ 'Supabase direct connections answer on IPv6 only, and a lot of home and',
296
+ 'office networks have no IPv6 at all. The session pooler answers on IPv4.',
297
+ '',
298
+ 'Supabase dashboard -> Project Settings -> Database -> Connection string,',
299
+ 'and take the "Session pooler" one instead.',
300
+ ],
301
+ // Not an SSL problem, whatever it looks like: this command turns encryption
302
+ // on for every connection that is not to this machine. An earlier draft of
303
+ // this message told people to add ?sslmode=require, which would have made
304
+ // things worse - the driver reads that as full certificate verification and
305
+ // refuses Supabase's own certificate authority outright.
306
+ ECONNRESET: [
307
+ 'The connection was cut while I was opening it.',
308
+ '',
309
+ 'Usually this is the network rather than the database - a VPN, a captive',
310
+ 'wifi portal, or a free Supabase project waking up.',
311
+ '',
312
+ 'Try it again. If it happens twice in a row, try it on a different',
313
+ 'network, such as a phone hotspot.',
314
+ ],
315
+ };
316
+
317
+ /**
318
+ * One failure, in plain English.
319
+ *
320
+ * Always returns something. A branch that has not been written yet is worse
321
+ * than a generic sentence, but a generic sentence with the original message
322
+ * kept underneath is not a dead end - somebody can search for it. What never
323
+ * survives is the stack: the person cannot use it and it makes the tool look
324
+ * like it broke rather than like it has something to tell them.
325
+ */
326
+ function explain(err, raw) {
327
+ const code = err && err.code ? String(err.code) : '';
328
+
329
+ if (BY_CODE[code]) return BY_CODE[code].slice();
330
+ if (BY_SYSCALL[code]) return BY_SYSCALL[code].slice();
331
+
332
+ const message = err && err.message ? String(err.message) : String(err);
333
+
334
+ // Certificate failures arrive with several different codes depending on
335
+ // which part of the chain gave up, so they are recognised by their wording.
336
+ if (/self[- ]signed certificate|unable to verify|CERT_|certificate/i.test(message)) {
337
+ return [
338
+ 'The database offered a security certificate I could not check.',
339
+ '',
340
+ 'This is normal for some hosts and not for others, so I stopped rather',
341
+ 'than trusting it quietly.',
342
+ '',
343
+ 'If this is your own database and you expected it, add ?sslmode=no-verify',
344
+ 'to the end of the connection string.',
345
+ ];
346
+ }
347
+
348
+ if (/timeout|timed out/i.test(message)) {
349
+ return BY_SYSCALL.ETIMEDOUT.slice();
350
+ }
351
+
352
+ if (/password|authentication/i.test(message)) {
353
+ return BY_CODE['28P01'].slice();
354
+ }
355
+
356
+ // Nothing matched. Say what happened and where, without pretending to know
357
+ // why, and without the stack.
358
+ const lines = [
359
+ 'I could not connect to the database.',
360
+ '',
361
+ 'The server said: ' + message,
362
+ ];
363
+ if (raw) {
364
+ lines.push('');
365
+ lines.push('I was trying to reach: ' + withoutSecret(raw));
366
+ lines.push('(your password is not shown, and was not written anywhere)');
367
+ }
368
+ return lines;
369
+ }
370
+
371
+ module.exports = {
372
+ readConnectionString: readConnectionString,
373
+ poolerWarning: poolerWarning,
374
+ explain: explain,
375
+ withoutSecret: withoutSecret,
376
+ PLACEHOLDERS: PLACEHOLDERS,
377
+ };