@alexify/migronaut 1.0.0 → 2.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/CHANGELOG.md +409 -1
- package/README.md +248 -24
- package/bin/migronaut.js +11 -3
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +757 -29
- package/migronaut.schema.json +191 -1
- package/package.json +27 -6
- package/src/bullmq/index.js +55 -0
- package/src/bullmq/jobs.js +454 -0
- package/src/bullmq/processor.js +608 -0
- package/src/bullmq/producer.js +424 -0
- package/src/bullmq/service.js +653 -0
- package/src/bullmq/wait.js +124 -0
- package/src/cli/args.js +12 -2
- package/src/cli/commands/baseline.js +45 -0
- package/src/cli/commands/converge.js +160 -0
- package/src/cli/commands/down.js +2 -0
- package/src/cli/commands/lock.js +2 -1
- package/src/cli/commands/redo.js +8 -1
- package/src/cli/commands/unlock.js +12 -2
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +10 -2
- package/src/cli/index.js +4 -0
- package/src/cli/shared.js +29 -7
- package/src/cli/table.js +105 -0
- package/src/core/audit.js +17 -3
- package/src/core/baseline.js +80 -0
- package/src/core/changelog.js +140 -24
- package/src/core/collections.js +372 -0
- package/src/core/config.js +125 -27
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +483 -0
- package/src/core/converge.js +867 -0
- package/src/core/import-runner.js +34 -6
- package/src/core/import.js +14 -7
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +71 -20
- package/src/core/migrator.js +805 -304
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +71 -71
- package/src/core/runner.js +70 -20
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +71 -1
- package/src/index.js +16 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +179 -0
- package/src/utils/collection-name.js +21 -0
- package/src/utils/error.js +18 -1
- package/src/utils/id.js +77 -0
- package/src/utils/loader.js +39 -21
- package/src/utils/logger.js +30 -12
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +57 -4
- package/src/utils/sanitize.js +8 -3
- package/src/utils/telemetry.js +393 -0
- package/src/utils/template.js +60 -12
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection names we accept for the changelog/lock collections,
|
|
3
|
+
* `import --from/--to` and declared collections: non-empty, no `$` or NUL
|
|
4
|
+
* (invalid server-side), and outside the reserved `system.` namespace — so no
|
|
5
|
+
* config value can ever point a read or write at a system collection.
|
|
6
|
+
*
|
|
7
|
+
* Lives here rather than in config.js because the collection-definition
|
|
8
|
+
* validator (core/collections.js) needs it too, and config.js requires that
|
|
9
|
+
* validator — keeping the predicate in config.js would make the two a cycle.
|
|
10
|
+
*/
|
|
11
|
+
function isCollectionName(value) {
|
|
12
|
+
return (
|
|
13
|
+
typeof value === 'string' &&
|
|
14
|
+
value.length > 0 &&
|
|
15
|
+
!value.includes('$') &&
|
|
16
|
+
!value.includes('\0') &&
|
|
17
|
+
!value.startsWith('system.')
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { isCollectionName };
|
package/src/utils/error.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const { MigronautError } = require('../errors/index.js');
|
|
1
2
|
const { redactUris } = require('./redact.js');
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -8,4 +9,20 @@ const { redactUris } = require('./redact.js');
|
|
|
8
9
|
*/
|
|
9
10
|
const errorText = (error) => redactUris(error instanceof Error ? error.message : String(error));
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* {@link errorText} joined with the wrapped cause: "Migration up failed: X"
|
|
14
|
+
* says WHICH migration failed, and its `context.cause` says WHY — the half
|
|
15
|
+
* forensics actually needs. Both halves are redacted (the cause is the raw
|
|
16
|
+
* thrown message). What the changelog's failure trace and a failed span's
|
|
17
|
+
* status message both carry.
|
|
18
|
+
*/
|
|
19
|
+
function errorWithCause(error) {
|
|
20
|
+
const message = errorText(error);
|
|
21
|
+
const cause =
|
|
22
|
+
error instanceof MigronautError && typeof error.context?.cause === 'string'
|
|
23
|
+
? errorText(error.context.cause)
|
|
24
|
+
: undefined;
|
|
25
|
+
return cause ? `${message} — ${cause}` : message;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { errorText, errorWithCause };
|
package/src/utils/id.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const { randomUUID } = require('node:crypto');
|
|
2
|
+
const { ConfigInvalidError } = require('../errors/index.js');
|
|
3
|
+
const { errorText } = require('./error.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Longest id migronaut accepts. Also the limit a queue worker enforces on a
|
|
7
|
+
* job's group id — one constant, so a producer can never mint an id its own
|
|
8
|
+
* worker would reject.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_ID_LENGTH = 128;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The default id: a random (v4) UUID. This module is the only place in `src/`
|
|
14
|
+
* that mints one — everything else asks for an id through it, which is what
|
|
15
|
+
* lets the `generateId` config option replace the format everywhere at once.
|
|
16
|
+
*/
|
|
17
|
+
const randomId = () => randomUUID();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return `value` when it is usable as an id, throw otherwise. An id is stored
|
|
21
|
+
* as a string field (changelog `runId`, lock `owner`, a job's `groupId`) and
|
|
22
|
+
* gated on by truthiness in the kit, so an empty or non-string value would
|
|
23
|
+
* silently switch off the reentrancy guard and the owner-scoped lock release.
|
|
24
|
+
*/
|
|
25
|
+
function assertId(value) {
|
|
26
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_LENGTH) {
|
|
27
|
+
throw new ConfigInvalidError(
|
|
28
|
+
`generateId must return a non-empty string of at most ${MAX_ID_LENGTH} characters`,
|
|
29
|
+
typeof value === 'string' ? { length: value.length } : { returned: typeof value },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Turn the `generateId` config option into the function the kit mints ids
|
|
37
|
+
* with. `undefined` keeps the default (`randomId`); anything else must be a
|
|
38
|
+
* function, and every id it returns is checked.
|
|
39
|
+
*
|
|
40
|
+
* The user's function is called bare — no arguments, no receiver — so a
|
|
41
|
+
* third-party generator passes straight through (`generateId: ulid`,
|
|
42
|
+
* `generateId: nanoid`): their first parameter means something of its own
|
|
43
|
+
* (a seed time, a size), and any argument migronaut passed would be read as it.
|
|
44
|
+
*
|
|
45
|
+
* It must be synchronous: the run id is minted in the same tick as the
|
|
46
|
+
* reentrancy guard that checks it, and a promise would be stored as a truthy
|
|
47
|
+
* non-id.
|
|
48
|
+
*/
|
|
49
|
+
function createIdGenerator(generateId) {
|
|
50
|
+
if (generateId === undefined) return randomId;
|
|
51
|
+
if (typeof generateId !== 'function') {
|
|
52
|
+
throw new ConfigInvalidError('generateId must be a function', {
|
|
53
|
+
generateId: typeof generateId,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return () => {
|
|
57
|
+
let value;
|
|
58
|
+
try {
|
|
59
|
+
value = generateId();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new ConfigInvalidError(
|
|
62
|
+
'generateId threw',
|
|
63
|
+
{ cause: errorText(error) },
|
|
64
|
+
{ cause: error },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (typeof value?.then === 'function') {
|
|
68
|
+
// The promise is dropped, so its rejection must not surface as an
|
|
69
|
+
// unhandled one on top of the error below.
|
|
70
|
+
value.then(undefined, () => {});
|
|
71
|
+
throw new ConfigInvalidError('generateId must be synchronous — it returned a promise');
|
|
72
|
+
}
|
|
73
|
+
return assertId(value);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { MAX_ID_LENGTH, assertId, createIdGenerator, randomId };
|
package/src/utils/loader.js
CHANGED
|
@@ -7,7 +7,7 @@ const { errorText } = require('./error.js');
|
|
|
7
7
|
/** TypeScript source extensions that require a TS-capable runtime to import */
|
|
8
8
|
const TS_EXTENSIONS = new Set(['.ts', '.mts', '.cts']);
|
|
9
9
|
|
|
10
|
-
/** Distinguishes reload URLs; see
|
|
10
|
+
/** Distinguishes reload URLs; see importUserFile */
|
|
11
11
|
let reloadCounter = 0;
|
|
12
12
|
|
|
13
13
|
/** Narrow an unknown value to a function */
|
|
@@ -36,9 +36,10 @@ function isUnsupportedTsSyntaxError(error) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* An actionable message for a dynamic-import failure whose cause is a
|
|
40
|
+
* `.ts`/`.mts`/`.cts` file the current runtime refused, or null to let the
|
|
41
|
+
* original error speak for itself. `noun` names what the file is ("migration",
|
|
42
|
+
* "collection definition").
|
|
42
43
|
*
|
|
43
44
|
* The shipped CLI runs as plain Node, whose type stripping (always present on
|
|
44
45
|
* the supported Node >= 22.18 range) handles erasable TypeScript only. Two
|
|
@@ -47,18 +48,29 @@ function isUnsupportedTsSyntaxError(error) {
|
|
|
47
48
|
* `--no-experimental-strip-types`), and non-erasable syntax such as `enum` or
|
|
48
49
|
* `namespace` (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`).
|
|
49
50
|
*/
|
|
50
|
-
function
|
|
51
|
+
function tsLoadMessageOrNull(filepath, error, noun) {
|
|
51
52
|
const ext = path.extname(filepath).toLowerCase();
|
|
52
53
|
if (!TS_EXTENSIONS.has(ext)) {
|
|
53
54
|
return null;
|
|
54
55
|
}
|
|
55
56
|
const name = path.basename(filepath);
|
|
56
|
-
let message;
|
|
57
57
|
if (isUnknownExtensionError(error)) {
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
return `Cannot load TypeScript ${noun} "${name}" — type stripping is disabled in this Node process. Re-enable it, run migronaut under a TypeScript loader (e.g. tsx), or author the ${noun} as .js.`;
|
|
59
|
+
}
|
|
60
|
+
if (isUnsupportedTsSyntaxError(error)) {
|
|
61
|
+
return `Cannot load TypeScript ${noun} "${name}" — it uses syntax Node's type stripping cannot erase (e.g. enum, namespace). Rewrite with erasable-only syntax, or run migronaut under a TypeScript loader (e.g. tsx).`;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Translate a dynamic-import failure of a migration into a clear
|
|
68
|
+
* MigrationInvalidExportError (see {@link tsLoadMessageOrNull}), or return
|
|
69
|
+
* null to let the original error propagate.
|
|
70
|
+
*/
|
|
71
|
+
function tsLoadErrorOrNull(filepath, error) {
|
|
72
|
+
const message = tsLoadMessageOrNull(filepath, error, 'migration');
|
|
73
|
+
if (message === null) {
|
|
62
74
|
return null;
|
|
63
75
|
}
|
|
64
76
|
return new MigrationInvalidExportError(
|
|
@@ -68,6 +80,21 @@ function tsLoadErrorOrNull(filepath, error) {
|
|
|
68
80
|
);
|
|
69
81
|
}
|
|
70
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Import a user-authored module (a migration, a collection definition).
|
|
85
|
+
*
|
|
86
|
+
* Node caches ESM modules by URL forever. A one-shot CLI never notices, but a
|
|
87
|
+
* long-lived process (a test runner, a dev server re-running migrations, a
|
|
88
|
+
* queue worker) would keep evaluating the version it first imported; with
|
|
89
|
+
* `reload` a unique query string forces a fresh evaluation. Off by default —
|
|
90
|
+
* it leaks a module per load. A monotonic counter, not Date.now(): two
|
|
91
|
+
* reloads in one millisecond must still get distinct URLs.
|
|
92
|
+
*/
|
|
93
|
+
function importUserFile(filepath, options = {}) {
|
|
94
|
+
const url = pathToFileURL(filepath).href;
|
|
95
|
+
return import(options.reload ? `${url}?migronaut=${++reloadCounter}` : url);
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
/**
|
|
72
99
|
* Dynamically load a migration file and validate its exports.
|
|
73
100
|
*
|
|
@@ -85,18 +112,9 @@ async function loadMigrationFile(filepath, options = {}) {
|
|
|
85
112
|
throw new MigrationFileNotFoundError('Migration file not found', { filepath });
|
|
86
113
|
}
|
|
87
114
|
|
|
88
|
-
// Node caches ESM modules by URL forever. A one-shot CLI never notices, but a
|
|
89
|
-
// long-lived process (a test runner, a dev server re-running migrations)
|
|
90
|
-
// would keep executing the version it first imported; a unique query string
|
|
91
|
-
// forces a fresh evaluation. Off by default — it leaks a module per load.
|
|
92
|
-
// A monotonic counter, not Date.now(): two reloads in one millisecond must
|
|
93
|
-
// still get distinct URLs.
|
|
94
|
-
const url = pathToFileURL(filepath).href;
|
|
95
|
-
const href = options.reload ? `${url}?migronaut=${++reloadCounter}` : url;
|
|
96
|
-
|
|
97
115
|
let imported;
|
|
98
116
|
try {
|
|
99
|
-
imported = await
|
|
117
|
+
imported = await importUserFile(filepath, { reload: options.reload });
|
|
100
118
|
} catch (error) {
|
|
101
119
|
const tsError = tsLoadErrorOrNull(filepath, error);
|
|
102
120
|
if (tsError) {
|
|
@@ -128,4 +146,4 @@ async function loadMigrationFile(filepath, options = {}) {
|
|
|
128
146
|
return migration;
|
|
129
147
|
}
|
|
130
148
|
|
|
131
|
-
module.exports = {
|
|
149
|
+
module.exports = { importUserFile, loadMigrationFile, tsLoadErrorOrNull, tsLoadMessageOrNull };
|
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;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { MigrationInvalidNameError } = require('../errors/index.js');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether `name` is a bare filename: a non-empty string with no path
|
|
5
|
+
* separator, no NUL byte, and not `.`/`..`. The single definition of the rule
|
|
6
|
+
* that keeps a migration name from escaping the migrations directory — shared
|
|
7
|
+
* by the kit's path resolution and by anything that accepts a name from
|
|
8
|
+
* outside the process (a queue job's payload).
|
|
9
|
+
*/
|
|
10
|
+
function isBareFilename(name) {
|
|
11
|
+
return (
|
|
12
|
+
typeof name === 'string' &&
|
|
13
|
+
name.length > 0 &&
|
|
14
|
+
name !== '.' &&
|
|
15
|
+
name !== '..' &&
|
|
16
|
+
!name.includes('/') &&
|
|
17
|
+
!name.includes('\\') &&
|
|
18
|
+
!name.includes('\0')
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Throw MigrationInvalidNameError unless `name` is a bare filename */
|
|
23
|
+
function assertMigrationName(name, context = {}) {
|
|
24
|
+
if (!isBareFilename(name)) {
|
|
25
|
+
throw new MigrationInvalidNameError(
|
|
26
|
+
'Invalid migration name — must be a bare filename with no path segments',
|
|
27
|
+
{ name, ...context },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { assertMigrationName, isBareFilename };
|
package/src/utils/redact.js
CHANGED
|
@@ -8,12 +8,65 @@
|
|
|
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
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The document values a server error can quote: an E11000 duplicate-key
|
|
54
|
+
* message ends with the offending key's values — an email, a phone number —
|
|
55
|
+
* which is the database's data, not an error's. Everything from `dup key: {`
|
|
56
|
+
* to the last `}` on that line is masked; the index name before it still says
|
|
57
|
+
* which constraint was violated.
|
|
58
|
+
*/
|
|
59
|
+
const DUPLICATE_KEY_VALUES = /(dup key: )\{[^\n]*\}/g;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* For text that leaves the process for a third party — a tracing backend, a
|
|
63
|
+
* queue that keeps failed jobs and serves them to dashboards: credentials
|
|
64
|
+
* masked (as everywhere) and the data values a server error quotes, too.
|
|
65
|
+
* Local log lines keep the values; they are what a developer debugs with.
|
|
66
|
+
*/
|
|
67
|
+
function redactOutbound(text) {
|
|
68
|
+
if (typeof text !== 'string') return text;
|
|
69
|
+
return redactUris(text).replace(DUPLICATE_KEY_VALUES, '$1{ <redacted> }');
|
|
17
70
|
}
|
|
18
71
|
|
|
19
72
|
/**
|
|
@@ -36,4 +89,4 @@ function redactDeep(value) {
|
|
|
36
89
|
return value;
|
|
37
90
|
}
|
|
38
91
|
|
|
39
|
-
module.exports = {
|
|
92
|
+
module.exports = { redactDeep, redactOutbound, redactUris };
|
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
|