@alexify/migronaut 1.0.0 → 2.0.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/CHANGELOG.md +89 -1
- package/README.md +42 -20
- package/bin/migronaut.js +11 -3
- package/index.d.ts +126 -14
- package/migronaut.schema.json +9 -0
- package/package.json +7 -2
- package/src/cli/commands/baseline.js +45 -0
- package/src/cli/commands/unlock.js +12 -2
- package/src/cli/exit-codes.js +1 -0
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +15 -3
- package/src/core/audit.js +17 -3
- package/src/core/baseline.js +80 -0
- package/src/core/changelog.js +74 -23
- package/src/core/config.js +25 -2
- package/src/core/import-runner.js +34 -6
- package/src/core/import.js +14 -7
- package/src/core/lock.js +28 -6
- package/src/core/migrator.js +296 -75
- package/src/core/run.js +33 -1
- package/src/core/runner.js +70 -20
- package/src/errors/index.js +15 -1
- package/src/index.js +8 -0
- package/src/utils/logger.js +30 -12
- package/src/utils/redact.js +36 -3
- package/src/utils/sanitize.js +8 -3
- package/src/utils/template.js +24 -10
package/src/core/runner.js
CHANGED
|
@@ -27,9 +27,9 @@ function isTransactionsUnsupported(error) {
|
|
|
27
27
|
* timeout buys is the *run* stopping instead of hanging forever — which also
|
|
28
28
|
* lets the lock's TTL expire, so a wedged migration no longer blocks every
|
|
29
29
|
* other instance indefinitely. Migrations that need real cancellation should
|
|
30
|
-
* watch `ctx.signal
|
|
30
|
+
* watch `ctx.signal`, which `onTimeout` aborts when the timer fires.
|
|
31
31
|
*/
|
|
32
|
-
async function withTimeout(promise, timeoutMs, name, direction) {
|
|
32
|
+
async function withTimeout(promise, timeoutMs, name, direction, onTimeout) {
|
|
33
33
|
if (!timeoutMs) return promise;
|
|
34
34
|
let timer;
|
|
35
35
|
try {
|
|
@@ -43,16 +43,19 @@ async function withTimeout(promise, timeoutMs, name, direction) {
|
|
|
43
43
|
// unhandledRejection long after the run already reported the
|
|
44
44
|
// timeout. Swallow it: the timeout is the reported failure.
|
|
45
45
|
Promise.resolve(promise).catch(() => {});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
},
|
|
54
|
-
),
|
|
46
|
+
const timeoutError = new MigrationTimeoutError(
|
|
47
|
+
`Migration ${direction} timed out after ${timeoutMs}ms: ${name}`,
|
|
48
|
+
{
|
|
49
|
+
name,
|
|
50
|
+
direction,
|
|
51
|
+
timeoutMs,
|
|
52
|
+
},
|
|
55
53
|
);
|
|
54
|
+
// Told, not just abandoned: the caller aborts the context's signal
|
|
55
|
+
// with this error, so a body that watches ctx.signal can stop
|
|
56
|
+
// writing instead of racing whoever acquires the lock next.
|
|
57
|
+
onTimeout?.(timeoutError);
|
|
58
|
+
reject(timeoutError);
|
|
56
59
|
}, timeoutMs);
|
|
57
60
|
timer.unref?.();
|
|
58
61
|
}),
|
|
@@ -77,7 +80,10 @@ async function withTimeout(promise, timeoutMs, name, direction) {
|
|
|
77
80
|
*
|
|
78
81
|
* On any error the `onError` hook is invoked before a
|
|
79
82
|
* MigrationExecutionFailedError is thrown — the error is never swallowed, and a
|
|
80
|
-
* throwing hook cannot mask the original failure.
|
|
83
|
+
* throwing hook cannot mask the original failure. The one exception: when the
|
|
84
|
+
* body succeeded and only the (non-transactional) changelog write failed, the
|
|
85
|
+
* error carries `context.phase = 'changelog-write'` and `onError` is not fired —
|
|
86
|
+
* the migration itself did not fail.
|
|
81
87
|
*/
|
|
82
88
|
async function runMigration(params) {
|
|
83
89
|
const { name, migration, direction, context, useTransaction, hooks, onSuccess, logger } = params;
|
|
@@ -87,30 +93,71 @@ async function runMigration(params) {
|
|
|
87
93
|
|
|
88
94
|
const start = Date.now();
|
|
89
95
|
let session;
|
|
90
|
-
|
|
96
|
+
// JavaScript cannot cancel a running body, but it can tell it to stop: this
|
|
97
|
+
// controller feeds the context's signal, so the documented "watch ctx.signal"
|
|
98
|
+
// advice covers the migration's own timeout too — not only lock loss and
|
|
99
|
+
// stop(). Without it a timed-out body keeps writing after the lock is
|
|
100
|
+
// released, racing whoever acquires it next.
|
|
101
|
+
const timedOut = new AbortController();
|
|
102
|
+
const signal = context.signal
|
|
103
|
+
? AbortSignal.any([context.signal, timedOut.signal])
|
|
104
|
+
: timedOut.signal;
|
|
105
|
+
let runtimeContext = { ...context, signal };
|
|
106
|
+
const onTimeout = (timeoutError) => timedOut.abort(timeoutError);
|
|
91
107
|
let duration = 0;
|
|
108
|
+
// 'body' while the migration's own code runs; 'changelog' once it committed
|
|
109
|
+
// and only the record write remains. The two failures need different
|
|
110
|
+
// reporting: a changelog failure after a committed body must not read as
|
|
111
|
+
// "the migration failed" — that invites a re-run of already-applied writes.
|
|
112
|
+
let phase = 'body';
|
|
92
113
|
|
|
93
114
|
try {
|
|
94
115
|
if (useTransaction) {
|
|
95
116
|
session = context.client.startSession();
|
|
96
|
-
runtimeContext = { ...
|
|
117
|
+
runtimeContext = { ...runtimeContext, session };
|
|
97
118
|
// withTransaction may run the body more than once when the driver retries
|
|
98
|
-
// a transient failure, so duration is re-measured on each attempt.
|
|
119
|
+
// a transient failure, so duration is re-measured on each attempt. The
|
|
120
|
+
// changelog write stays inside the transaction, so a failure there
|
|
121
|
+
// aborts the body's writes too — 'body' phase is accurate throughout.
|
|
99
122
|
await session.withTransaction(async () => {
|
|
100
123
|
const attemptStart = Date.now();
|
|
101
|
-
await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
|
|
124
|
+
await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
|
|
102
125
|
duration = Date.now() - attemptStart;
|
|
103
126
|
await onSuccess?.(duration, session);
|
|
104
127
|
});
|
|
105
128
|
} else {
|
|
106
|
-
await withTimeout(fn(runtimeContext), timeoutMs, name, direction);
|
|
129
|
+
await withTimeout(fn(runtimeContext), timeoutMs, name, direction, onTimeout);
|
|
107
130
|
duration = Date.now() - start;
|
|
131
|
+
phase = 'changelog';
|
|
108
132
|
await onSuccess?.(duration, undefined);
|
|
109
133
|
}
|
|
110
134
|
|
|
111
135
|
return { duration };
|
|
112
136
|
} catch (error) {
|
|
113
137
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
138
|
+
// Failures deserve timing data as much as successes — a slow-then-failing
|
|
139
|
+
// migration is exactly what an operator alerts on.
|
|
140
|
+
const elapsed = Date.now() - start;
|
|
141
|
+
|
|
142
|
+
// Without a transaction the body's writes are already committed when the
|
|
143
|
+
// changelog write fails — say exactly that, instead of the generic
|
|
144
|
+
// "migration failed" that would invite re-running committed writes. The
|
|
145
|
+
// onError hook is for migration failures, so it does not fire here.
|
|
146
|
+
if (phase === 'changelog') {
|
|
147
|
+
throw new MigrationExecutionFailedError(
|
|
148
|
+
`Migration ${direction} succeeded but recording it in the changelog failed: ${name} — ` +
|
|
149
|
+
'its own writes are committed; verify the changelog before re-running',
|
|
150
|
+
{
|
|
151
|
+
name,
|
|
152
|
+
direction,
|
|
153
|
+
phase: 'changelog-write',
|
|
154
|
+
bodySucceeded: true,
|
|
155
|
+
durationMs: elapsed,
|
|
156
|
+
cause: err.message,
|
|
157
|
+
},
|
|
158
|
+
{ cause: err },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
114
161
|
|
|
115
162
|
if (hooks?.onError) {
|
|
116
163
|
// A throwing onError hook must not replace the real cause.
|
|
@@ -122,14 +169,17 @@ async function runMigration(params) {
|
|
|
122
169
|
}
|
|
123
170
|
}
|
|
124
171
|
|
|
125
|
-
if (err instanceof MigrationTimeoutError)
|
|
172
|
+
if (err instanceof MigrationTimeoutError) {
|
|
173
|
+
err.context = { durationMs: elapsed, ...err.context };
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
126
176
|
// A standalone deployment refusing the transaction is a topology problem,
|
|
127
177
|
// not a bug in the migration — say so instead of blaming the file.
|
|
128
178
|
if (useTransaction && isTransactionsUnsupported(err)) {
|
|
129
179
|
throw new TransactionsUnsupportedError(
|
|
130
180
|
`Cannot run ${name} in a transaction — this deployment is standalone. ` +
|
|
131
181
|
'Set useTransaction: false, or run against a replica set / mongos.',
|
|
132
|
-
{ name, direction, cause: err.message },
|
|
182
|
+
{ name, direction, durationMs: elapsed, cause: err.message },
|
|
133
183
|
{ cause: err },
|
|
134
184
|
);
|
|
135
185
|
}
|
|
@@ -137,7 +187,7 @@ async function runMigration(params) {
|
|
|
137
187
|
`Migration ${direction} failed: ${name}`,
|
|
138
188
|
// The message is duplicated into context because that is what survives
|
|
139
189
|
// JSON serialization; `cause` keeps the real Error (and its stack).
|
|
140
|
-
{ name, direction, cause: err.message },
|
|
190
|
+
{ name, direction, durationMs: elapsed, cause: err.message },
|
|
141
191
|
{ cause: err },
|
|
142
192
|
);
|
|
143
193
|
} finally {
|
package/src/errors/index.js
CHANGED
|
@@ -183,7 +183,7 @@ class ImportTargetNotEmptyError extends MigronautError {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
/** Thrown when attempting to roll back a
|
|
186
|
+
/** Thrown when attempting to roll back a forward-only (imported or baselined) migration */
|
|
187
187
|
class IrreversibleMigrationError extends MigronautError {
|
|
188
188
|
constructor(message, context, options) {
|
|
189
189
|
super('MIGRATION_IRREVERSIBLE', message, context, options);
|
|
@@ -191,6 +191,19 @@ class IrreversibleMigrationError extends MigronautError {
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Thrown by a bulk `up` under `onOutOfOrder: 'error'` when a pending migration
|
|
196
|
+
* sorts before the newest applied one — a file merged late from a parallel
|
|
197
|
+
* branch, which would otherwise run after migrations authored later and leave
|
|
198
|
+
* environments with different effective apply orders.
|
|
199
|
+
*/
|
|
200
|
+
class OutOfOrderMigrationError extends MigronautError {
|
|
201
|
+
constructor(message, context, options) {
|
|
202
|
+
super('MIGRATION_OUT_OF_ORDER', message, context, options);
|
|
203
|
+
this.name = 'OutOfOrderMigrationError';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
194
207
|
module.exports = {
|
|
195
208
|
MigronautError,
|
|
196
209
|
LockAlreadyHeldError,
|
|
@@ -212,4 +225,5 @@ module.exports = {
|
|
|
212
225
|
NotAppliedError,
|
|
213
226
|
ImportTargetNotEmptyError,
|
|
214
227
|
IrreversibleMigrationError,
|
|
228
|
+
OutOfOrderMigrationError,
|
|
215
229
|
};
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { EXIT_CODES } = require('./cli/exit-codes.js');
|
|
2
2
|
const { MigratorKit } = require('./core/migrator.js');
|
|
3
3
|
const { pendingMigrations, runMigrations } = require('./core/run.js');
|
|
4
|
+
const { createLogger } = require('./utils/logger.js');
|
|
4
5
|
const {
|
|
5
6
|
ChecksumMismatchError,
|
|
6
7
|
ConfigFileExistsError,
|
|
@@ -21,6 +22,7 @@ const {
|
|
|
21
22
|
TransactionsUnsupportedError,
|
|
22
23
|
MigronautError,
|
|
23
24
|
NotAppliedError,
|
|
25
|
+
OutOfOrderMigrationError,
|
|
24
26
|
RunAbortedError,
|
|
25
27
|
} = require('./errors/index.js');
|
|
26
28
|
|
|
@@ -32,6 +34,11 @@ module.exports = {
|
|
|
32
34
|
pendingMigrations,
|
|
33
35
|
runMigrations,
|
|
34
36
|
|
|
37
|
+
// The default console logger, for programmatic callers who want migronaut's
|
|
38
|
+
// own output at a chosen level (e.g. createLogger(process.stdout, 'debug'))
|
|
39
|
+
// without hand-writing a four-method logger
|
|
40
|
+
createLogger,
|
|
41
|
+
|
|
35
42
|
// The CLI's exit-code map, for wrappers that mirror its semantics
|
|
36
43
|
EXIT_CODES,
|
|
37
44
|
|
|
@@ -55,5 +62,6 @@ module.exports = {
|
|
|
55
62
|
TransactionsUnsupportedError,
|
|
56
63
|
MigronautError,
|
|
57
64
|
NotAppliedError,
|
|
65
|
+
OutOfOrderMigrationError,
|
|
58
66
|
RunAbortedError,
|
|
59
67
|
};
|
package/src/utils/logger.js
CHANGED
|
@@ -52,6 +52,13 @@ function createLogger(stream = process.stdout, level = 'info') {
|
|
|
52
52
|
|
|
53
53
|
const hasMethod = (value, name) => typeof value?.[name] === 'function';
|
|
54
54
|
|
|
55
|
+
/** A sink is usable when it exposes any of the four level methods */
|
|
56
|
+
const isUsableSink = (value) =>
|
|
57
|
+
hasMethod(value, 'info') ||
|
|
58
|
+
hasMethod(value, 'debug') ||
|
|
59
|
+
hasMethod(value, 'warn') ||
|
|
60
|
+
hasMethod(value, 'error');
|
|
61
|
+
|
|
55
62
|
/**
|
|
56
63
|
* Wrap a sink method so a throwing user logger can never break a migration run.
|
|
57
64
|
* `pinoStyle` swaps the argument order to `(fields, msg)`, which is what pino
|
|
@@ -75,10 +82,12 @@ const adapters = new WeakMap();
|
|
|
75
82
|
* Resolve the effective logger from a config value: `null` → silent,
|
|
76
83
|
* `undefined` → default console logger, otherwise the user's logger adapted
|
|
77
84
|
* to the four-method surface. A pino-style `child` is bound once with a
|
|
78
|
-
* `component` field; a missing
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
85
|
+
* `component` field; a missing method falls back to the nearest present one
|
|
86
|
+
* of similar severity (a warn/error-only logger keeps its warn/error output —
|
|
87
|
+
* its missing debug/info become no-ops, never a reason to silence failures),
|
|
88
|
+
* and every call is guarded so a throwing logger can never abort a
|
|
89
|
+
* half-applied run. A structurally unfit value (no level method at all)
|
|
90
|
+
* silences output instead of crashing.
|
|
82
91
|
*
|
|
83
92
|
* A logger exposing `child()` is treated as pino-style, so structured fields
|
|
84
93
|
* are passed as the first argument rather than the second.
|
|
@@ -86,21 +95,30 @@ const adapters = new WeakMap();
|
|
|
86
95
|
function resolveLogger(logger) {
|
|
87
96
|
if (logger === null) return silentLogger;
|
|
88
97
|
if (logger === undefined) return createLogger();
|
|
89
|
-
if (typeof logger !== 'object' ||
|
|
98
|
+
if (typeof logger !== 'object' || !isUsableSink(logger)) {
|
|
90
99
|
return silentLogger;
|
|
91
100
|
}
|
|
92
101
|
const cached = adapters.get(logger);
|
|
93
102
|
if (cached !== undefined) return cached;
|
|
94
103
|
const pinoStyle = hasMethod(logger, 'child');
|
|
95
104
|
const child = pinoStyle ? logger.child({ component: 'migronaut' }) : null;
|
|
96
|
-
const sink = child && (
|
|
97
|
-
const
|
|
98
|
-
|
|
105
|
+
const sink = child && isUsableSink(child) ? child : logger;
|
|
106
|
+
const noop = () => {};
|
|
107
|
+
// Fallback preference per level: same severity first, then the neighbors a
|
|
108
|
+
// reader of that sink would expect. debug/info never escalate to warn/error
|
|
109
|
+
// (running commentary must not masquerade as problems); warn/error always
|
|
110
|
+
// find SOME sink so failures stay visible.
|
|
111
|
+
const pick = (order) => {
|
|
112
|
+
for (const name of order) {
|
|
113
|
+
if (hasMethod(sink, name)) return sink[name].bind(sink);
|
|
114
|
+
}
|
|
115
|
+
return noop;
|
|
116
|
+
};
|
|
99
117
|
const adapter = {
|
|
100
|
-
debug: guard(pick('debug'), pinoStyle),
|
|
101
|
-
info: guard(pick('info'), pinoStyle),
|
|
102
|
-
warn: guard(pick('warn'), pinoStyle),
|
|
103
|
-
error: guard(pick('error'), pinoStyle),
|
|
118
|
+
debug: guard(pick(['debug', 'info']), pinoStyle),
|
|
119
|
+
info: guard(pick(['info', 'debug']), pinoStyle),
|
|
120
|
+
warn: guard(pick(['warn', 'info', 'debug', 'error']), pinoStyle),
|
|
121
|
+
error: guard(pick(['error', 'warn', 'info', 'debug']), pinoStyle),
|
|
104
122
|
};
|
|
105
123
|
adapters.set(logger, adapter);
|
|
106
124
|
return adapter;
|
package/src/utils/redact.js
CHANGED
|
@@ -8,12 +8,45 @@
|
|
|
8
8
|
* and the URI may sit anywhere inside a larger message (unlike
|
|
9
9
|
* `maskUriCredentials` in template.js, which is anchored to a whole-string URI).
|
|
10
10
|
*/
|
|
11
|
-
const URI_CREDENTIALS = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^:@/\s]
|
|
11
|
+
const URI_CREDENTIALS = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^:@/\s]*):([^@/\s]+)@/g;
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Query parameters whose value is a secret. The userinfo form is not the only
|
|
15
|
+
* place a MongoDB URI carries credentials: TLS key passphrases and proxy
|
|
16
|
+
* passwords travel as plain query parameters and would otherwise survive
|
|
17
|
+
* redaction into logs, error context and `--json` output.
|
|
18
|
+
*/
|
|
19
|
+
const URI_QUERY_SECRETS =
|
|
20
|
+
/([?&](?:tlsCertificateKeyFilePassword|proxyPassword|sslKeyPassword)=)[^&\s]+/gi;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `authMechanismProperties` is a comma-separated `KEY:VALUE` list; only the
|
|
24
|
+
* values of secret-bearing keys (AWS_SESSION_TOKEN et al.) are masked, so
|
|
25
|
+
* non-secret properties (SERVICE_NAME, …) stay readable.
|
|
26
|
+
*/
|
|
27
|
+
const AUTH_MECHANISM_PROPS = /([?&]authMechanismProperties=)([^&\s]+)/gi;
|
|
28
|
+
const SENSITIVE_PROP_KEY = /TOKEN|SECRET|PASSWORD/i;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mask credentials anywhere in `text`: `scheme://user:secret@` (an empty
|
|
32
|
+
* username still hides the password), secret-bearing query parameters, and
|
|
33
|
+
* secret values inside `authMechanismProperties`.
|
|
34
|
+
*/
|
|
14
35
|
function redactUris(text) {
|
|
15
36
|
if (typeof text !== 'string') return text;
|
|
16
|
-
return text
|
|
37
|
+
return text
|
|
38
|
+
.replace(URI_CREDENTIALS, '$1$2:****@')
|
|
39
|
+
.replace(URI_QUERY_SECRETS, '$1****')
|
|
40
|
+
.replace(AUTH_MECHANISM_PROPS, (_match, prefix, value) => {
|
|
41
|
+
const pairs = value.split(',');
|
|
42
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
43
|
+
const colon = pairs[i].indexOf(':');
|
|
44
|
+
if (colon === -1) continue;
|
|
45
|
+
const key = pairs[i].slice(0, colon);
|
|
46
|
+
if (SENSITIVE_PROP_KEY.test(key)) pairs[i] = `${key}:****`;
|
|
47
|
+
}
|
|
48
|
+
return `${prefix}${pairs.join(',')}`;
|
|
49
|
+
});
|
|
17
50
|
}
|
|
18
51
|
|
|
19
52
|
/**
|
package/src/utils/sanitize.js
CHANGED
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
* clear the screen, or restyle everything printed after them.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Control characters stripped from untrusted values (tab and newline survive).
|
|
13
|
+
* The full C1 block (U+0080-U+009F) is included: CSI, OSC, DCS, PM and APC are
|
|
14
|
+
* single-codepoint escape introducers on terminals that decode C1, and none of
|
|
15
|
+
* them is ever legitimate text.
|
|
16
|
+
*/
|
|
12
17
|
// oxlint-disable-next-line no-control-regex -- stripping control characters is the point
|
|
13
|
-
const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\
|
|
18
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u0080-\u009f]/g;
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* SGR color sequences (`ESC[…m`) to preserve, or a control character to drop.
|
|
@@ -19,7 +24,7 @@ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f\u009b]/g;
|
|
|
19
24
|
* titles — has no legitimate reason to be in a log line.
|
|
20
25
|
*/
|
|
21
26
|
// oxlint-disable-next-line no-control-regex -- stripping control characters is the point
|
|
22
|
-
const SGR_OR_CONTROL = /(\u001b\[[0-9;]*m)|[\u0000-\u0008\u000b-\u001f\u007f\
|
|
27
|
+
const SGR_OR_CONTROL = /(\u001b\[[0-9;]*m)|[\u0000-\u0008\u000b-\u001f\u007f\u0080-\u009f]/g;
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Strip every terminal control character from an untrusted value. For data
|
package/src/utils/template.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
} = require('../errors/index.js');
|
|
9
9
|
const { formatStamp } = require('./date.js');
|
|
10
10
|
const { errorText } = require('./error.js');
|
|
11
|
+
const { redactUris } = require('./redact.js');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Convert an arbitrary migration name into a kebab-case slug. Unicode-aware —
|
|
@@ -255,21 +256,34 @@ const exportStatement = (esm, expression) =>
|
|
|
255
256
|
* Mask the password in a connection URI (`user:secret@` → `user:****@`) so a
|
|
256
257
|
* generated config file never carries plaintext credentials. Regex-based, not
|
|
257
258
|
* `new URL()` — multi-host mongodb URIs (`mongodb://h1:27017,h2:27017/db`) fail
|
|
258
|
-
* WHATWG URL parsing. `hasCredentials` is true whenever a userinfo part exists
|
|
259
|
-
* `masked` only when a
|
|
259
|
+
* WHATWG URL parsing. `hasCredentials` is true whenever a userinfo part exists
|
|
260
|
+
* or a query-string secret was masked; `masked` only when a secret was
|
|
261
|
+
* actually replaced.
|
|
260
262
|
*/
|
|
261
263
|
function maskUriCredentials(uri) {
|
|
262
264
|
const match = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^@/]+)@(.*)$/.exec(uri);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
+
let out = uri;
|
|
266
|
+
let hasCredentials = false;
|
|
267
|
+
let masked = false;
|
|
268
|
+
if (match) {
|
|
269
|
+
const [, scheme, userinfo, rest] = match;
|
|
270
|
+
hasCredentials = true;
|
|
271
|
+
const colon = userinfo.indexOf(':');
|
|
272
|
+
if (colon !== -1 && colon !== userinfo.length - 1) {
|
|
273
|
+
const username = userinfo.slice(0, colon);
|
|
274
|
+
out = `${scheme}${username}:****@${rest}`;
|
|
275
|
+
masked = true;
|
|
276
|
+
}
|
|
265
277
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
278
|
+
// Secrets can also travel as query parameters (TLS key passphrases, proxy
|
|
279
|
+
// passwords, session tokens) — redactUris masks exactly those, and an
|
|
280
|
+
// already-masked `user:****@` round-trips unchanged, so comparing the
|
|
281
|
+
// result tells whether a query secret was found.
|
|
282
|
+
const queryMasked = redactUris(out);
|
|
283
|
+
if (queryMasked !== out) {
|
|
284
|
+
return { uri: queryMasked, hasCredentials: true, masked: true };
|
|
270
285
|
}
|
|
271
|
-
|
|
272
|
-
return { uri: `${scheme}${username}:****@${rest}`, hasCredentials: true, masked: true };
|
|
286
|
+
return { uri: out, hasCredentials, masked };
|
|
273
287
|
}
|
|
274
288
|
|
|
275
289
|
/** Merge caller-supplied config values over the built-in defaults */
|