@zudojs/config 1.0.1 → 1.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/README.md +30 -5
- package/dist/configLoader/configLoader.core.d.ts +7 -2
- package/dist/configLoader/configLoader.core.js +46 -5
- package/dist/configLoader/configLoader.reload.d.ts +22 -0
- package/dist/configLoader/configLoader.reload.js +48 -0
- package/dist/configManager/configManager.core.js +4 -17
- package/dist/configManager/configManager.secrets.d.ts +27 -0
- package/dist/configManager/configManager.secrets.js +69 -0
- package/dist/configSource/configSource.environment.d.ts +6 -4
- package/dist/configSource/configSource.environment.js +2 -9
- package/dist/configSource/configSource.sensitive.d.ts +26 -0
- package/dist/configSource/configSource.sensitive.js +65 -0
- package/dist/configSource/index.d.ts +1 -0
- package/dist/configSource/index.js +1 -0
- package/dist/configValue/configValue.core.d.ts +14 -1
- package/dist/configValue/configValue.core.js +23 -13
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
Layered configuration management with multiple sources, priority-based
|
|
4
4
|
merging, schema validation, and sensitive-value redaction.
|
|
5
5
|
|
|
6
|
+
<!-- zudo-docs:start -->
|
|
7
|
+
|
|
8
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-config](https://zudojs.oyinlola.site/docs/packages-config) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-config.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
9
|
+
|
|
10
|
+
<!-- zudo-docs:end -->
|
|
11
|
+
|
|
6
12
|
## Installation
|
|
7
13
|
|
|
8
14
|
```bash
|
|
@@ -58,8 +64,8 @@ priorities are applied in registration order (last registered wins).
|
|
|
58
64
|
- `createEnvironmentConfigSource({ prefix, env?, priority?, keyMapper?, isSensitive? })`
|
|
59
65
|
— reads environment variables. Values stay RAW STRINGS (use the typed
|
|
60
66
|
accessors to coerce, so `"false"` becomes `false` instead of a truthy
|
|
61
|
-
string). Names matching
|
|
62
|
-
|
|
67
|
+
string). Names matching `isSensitiveConfigKey` are reported as
|
|
68
|
+
sensitive and therefore redacted by `toSafeObject()`.
|
|
63
69
|
- `createCustomConfigSource(name, loader, options?)` — async loader
|
|
64
70
|
function returning `{ values, source, type }`. Include
|
|
65
71
|
`sensitiveKeys: ["some.key"]` in the result to mark values as
|
|
@@ -83,6 +89,13 @@ const remote = createCustomConfigSource("vault", async () => ({
|
|
|
83
89
|
}));
|
|
84
90
|
```
|
|
85
91
|
|
|
92
|
+
Whatever the source, the loader also marks an entry sensitive when
|
|
93
|
+
`isSensitiveConfigEntry(key, value)` flags it: a key naming a password,
|
|
94
|
+
secret, token, API/private key, credential, DSN, database URL or `*_key`
|
|
95
|
+
(in any dotted segment, case-insensitive), a nested object containing such
|
|
96
|
+
a key, or a URL with embedded `user:password@` credentials. Returning
|
|
97
|
+
`false` from `isSensitive` or omitting `sensitiveKeys` cannot un-mark these.
|
|
98
|
+
|
|
86
99
|
## Typed access
|
|
87
100
|
|
|
88
101
|
`ConfigManager` (and the underlying `ConfigResolver`) offer typed
|
|
@@ -95,6 +108,9 @@ const db = manager.scoped("db");
|
|
|
95
108
|
db.string("host", "localhost");
|
|
96
109
|
```
|
|
97
110
|
|
|
111
|
+
`number()` accepts decimal notation only: `"0x1F90"`, `"0b11"` and `"0o17"`
|
|
112
|
+
are rejected rather than silently becoming 8080, 3 and 15.
|
|
113
|
+
|
|
98
114
|
## Schema validation
|
|
99
115
|
|
|
100
116
|
Schemas are plain objects validated by this package (no external
|
|
@@ -118,7 +134,10 @@ so nested constraints are enforced by `validate()`, `resolve()` and the
|
|
|
118
134
|
standalone `validateConfigValue`.
|
|
119
135
|
|
|
120
136
|
`validate()` throws on failure and marks entries whose schema has
|
|
121
|
-
`secret: true` as sensitive
|
|
137
|
+
`secret: true` as sensitive, at any depth: a secret declared inside a
|
|
138
|
+
nested object schema, or as a dotted key such as `"db.password"`, marks
|
|
139
|
+
the store entry that holds it (for a nested `db` object, the whole `db`
|
|
140
|
+
entry is redacted). Individual values can be validated with
|
|
122
141
|
`manager.resolve(key, schema)` or the standalone
|
|
123
142
|
`validateConfigValue` / `validateConfigObject` functions.
|
|
124
143
|
|
|
@@ -133,7 +152,12 @@ standalone `validateConfigValue`.
|
|
|
133
152
|
|
|
134
153
|
`manager.reload()` re-runs all sources. Values set at runtime via
|
|
135
154
|
`manager.set()` and `initialValues` survive loads; sources only
|
|
136
|
-
overwrite entries at equal or higher priority.
|
|
155
|
+
overwrite entries at equal or higher priority. A reload rebuilds every
|
|
156
|
+
source-provided value from scratch, so a value a source no longer
|
|
157
|
+
provides disappears (letting a lower-priority default show through), and
|
|
158
|
+
the store is only updated once every source has loaded — a failing
|
|
159
|
+
source leaves the previous configuration intact. An entry that was
|
|
160
|
+
sensitive before a reload stays sensitive.
|
|
137
161
|
|
|
138
162
|
## Untrusted input
|
|
139
163
|
|
|
@@ -141,7 +165,8 @@ Configuration frequently originates from files, environment variables
|
|
|
141
165
|
or remote services. Keys such as `__proto__`, `constructor` and
|
|
142
166
|
`prototype` are copied with `Object.defineProperty`, never plain
|
|
143
167
|
assignment, so a hostile key becomes an ordinary own property and can
|
|
144
|
-
never replace an object's prototype
|
|
168
|
+
never replace an object's prototype (this includes
|
|
169
|
+
`toConfigJsonValue` and `configValueToString`). Schema properties are read as OWN
|
|
145
170
|
properties, so a schema property named `constructor` is reported as
|
|
146
171
|
missing rather than matching an inherited function.
|
|
147
172
|
|
|
@@ -94,7 +94,10 @@ export declare class ConfigLoader {
|
|
|
94
94
|
/**
|
|
95
95
|
* Reloads configuration.
|
|
96
96
|
*
|
|
97
|
-
* The current store is replaced with freshly loaded values.
|
|
97
|
+
* The current store is replaced with freshly loaded values. When the
|
|
98
|
+
* loader layers onto a shared store (`clearStore: false`), values that
|
|
99
|
+
* sources no longer provide are dropped, runtime and initial values are
|
|
100
|
+
* kept, and the store is only updated once every source has loaded.
|
|
98
101
|
*/
|
|
99
102
|
reload(): Promise<ConfigLoadResult>;
|
|
100
103
|
/**
|
|
@@ -140,7 +143,9 @@ export declare class ConfigLoader {
|
|
|
140
143
|
* source overwrites, so among equal priorities the last-applied
|
|
141
144
|
* (last-registered) source wins.
|
|
142
145
|
*
|
|
143
|
-
* Keys listed in the result's `sensitiveKeys` are marked sensitive
|
|
146
|
+
* Keys listed in the result's `sensitiveKeys` are marked sensitive, as
|
|
147
|
+
* is any key or value that `isSensitiveConfigEntry` flags, whatever the
|
|
148
|
+
* source type.
|
|
144
149
|
* Once an entry is sensitive it stays sensitive even when a later
|
|
145
150
|
* source overwrites its value.
|
|
146
151
|
*/
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { cloneConfigValue } from "../configValue/configValue.core.js";
|
|
2
2
|
import { createConfigEntry } from "../configEntry/configEntry.type.js";
|
|
3
3
|
import { loadConfigSourceStrict, sortConfigSources, } from "../configSource/configSource.core.js";
|
|
4
|
+
import { isSensitiveConfigEntry } from "../configSource/configSource.sensitive.js";
|
|
5
|
+
import { commitReloadStaging, createReloadStaging, } from "./configLoader.reload.js";
|
|
4
6
|
import { createConfigStore } from "../configStore/configStore.factory.js";
|
|
5
7
|
/**
|
|
6
8
|
* Configuration loader.
|
|
@@ -128,12 +130,47 @@ export class ConfigLoader {
|
|
|
128
130
|
/**
|
|
129
131
|
* Reloads configuration.
|
|
130
132
|
*
|
|
131
|
-
* The current store is replaced with freshly loaded values.
|
|
133
|
+
* The current store is replaced with freshly loaded values. When the
|
|
134
|
+
* loader layers onto a shared store (`clearStore: false`), values that
|
|
135
|
+
* sources no longer provide are dropped, runtime and initial values are
|
|
136
|
+
* kept, and the store is only updated once every source has loaded.
|
|
132
137
|
*/
|
|
133
138
|
async reload() {
|
|
134
139
|
this.assertActive();
|
|
135
140
|
this.loaded = false;
|
|
136
|
-
|
|
141
|
+
if (this.clearStore) {
|
|
142
|
+
return this.load();
|
|
143
|
+
}
|
|
144
|
+
// Layered stores (clearStore: false) are rebuilt off to the side and
|
|
145
|
+
// committed only once every source has loaded: see
|
|
146
|
+
// configLoader.reload.ts.
|
|
147
|
+
const staging = createReloadStaging(this.store, new Set(this.sources.map((source) => source.name)), this.freeze);
|
|
148
|
+
const stagingLoader = new ConfigLoader({
|
|
149
|
+
sources: this.sources,
|
|
150
|
+
context: this.context,
|
|
151
|
+
store: staging,
|
|
152
|
+
clearStore: false,
|
|
153
|
+
freeze: this.freeze,
|
|
154
|
+
onSourceLoaded: this.onSourceLoaded,
|
|
155
|
+
onSourceError: this.onSourceError,
|
|
156
|
+
});
|
|
157
|
+
this.loading = true;
|
|
158
|
+
try {
|
|
159
|
+
const staged = await stagingLoader.load();
|
|
160
|
+
commitReloadStaging(this.store, staging);
|
|
161
|
+
const result = {
|
|
162
|
+
...staged,
|
|
163
|
+
store: this.store,
|
|
164
|
+
entries: this.store.getEntries(),
|
|
165
|
+
};
|
|
166
|
+
this.lastResult = result;
|
|
167
|
+
this.loaded = true;
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
this.loading = false;
|
|
172
|
+
stagingLoader.detach();
|
|
173
|
+
}
|
|
137
174
|
}
|
|
138
175
|
/**
|
|
139
176
|
* Loads only selected sources.
|
|
@@ -232,7 +269,9 @@ export class ConfigLoader {
|
|
|
232
269
|
* source overwrites, so among equal priorities the last-applied
|
|
233
270
|
* (last-registered) source wins.
|
|
234
271
|
*
|
|
235
|
-
* Keys listed in the result's `sensitiveKeys` are marked sensitive
|
|
272
|
+
* Keys listed in the result's `sensitiveKeys` are marked sensitive, as
|
|
273
|
+
* is any key or value that `isSensitiveConfigEntry` flags, whatever the
|
|
274
|
+
* source type.
|
|
236
275
|
* Once an entry is sensitive it stays sensitive even when a later
|
|
237
276
|
* source overwrites its value.
|
|
238
277
|
*/
|
|
@@ -248,7 +287,9 @@ export class ConfigLoader {
|
|
|
248
287
|
source: source.name,
|
|
249
288
|
sourceType: source.type,
|
|
250
289
|
priority,
|
|
251
|
-
sensitive: sensitiveKeys.has(key) ||
|
|
290
|
+
sensitive: sensitiveKeys.has(key) ||
|
|
291
|
+
(existing?.sensitive ?? false) ||
|
|
292
|
+
isSensitiveConfigEntry(key, value),
|
|
252
293
|
resolved: true,
|
|
253
294
|
});
|
|
254
295
|
}
|
|
@@ -302,7 +343,7 @@ export function sourceResultsToEntries(results, sources) {
|
|
|
302
343
|
source: result.source,
|
|
303
344
|
sourceType: result.type,
|
|
304
345
|
priority: source?.priority ?? 0,
|
|
305
|
-
sensitive: sensitiveKeys.has(key),
|
|
346
|
+
sensitive: sensitiveKeys.has(key) || isSensitiveConfigEntry(key, value),
|
|
306
347
|
}));
|
|
307
348
|
}
|
|
308
349
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic reload support for the configuration loader.
|
|
3
|
+
*
|
|
4
|
+
* Reloading used to re-apply sources on top of the live store without
|
|
5
|
+
* clearing it. A value that a higher-priority source stopped providing
|
|
6
|
+
* then stayed forever, because its entry still carried the higher
|
|
7
|
+
* priority and no lower source could replace it; and a source failing
|
|
8
|
+
* partway left the store half-applied.
|
|
9
|
+
*/
|
|
10
|
+
import type { ConfigStore } from "../configStore/configStore.core.js";
|
|
11
|
+
/**
|
|
12
|
+
* Creates a staging store holding only the entries no source owns
|
|
13
|
+
* (runtime writes, initial values).
|
|
14
|
+
*/
|
|
15
|
+
export declare function createReloadStaging(store: ConfigStore, sourceNames: ReadonlySet<string>, freeze: boolean): ConfigStore;
|
|
16
|
+
/**
|
|
17
|
+
* Copies a fully loaded staging store into the live store: keys the
|
|
18
|
+
* reload no longer produces are removed, the rest are written. An entry
|
|
19
|
+
* that was sensitive before the reload stays sensitive.
|
|
20
|
+
*/
|
|
21
|
+
export declare function commitReloadStaging(store: ConfigStore, staging: ConfigStore): void;
|
|
22
|
+
//# sourceMappingURL=configLoader.reload.d.ts.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic reload support for the configuration loader.
|
|
3
|
+
*
|
|
4
|
+
* Reloading used to re-apply sources on top of the live store without
|
|
5
|
+
* clearing it. A value that a higher-priority source stopped providing
|
|
6
|
+
* then stayed forever, because its entry still carried the higher
|
|
7
|
+
* priority and no lower source could replace it; and a source failing
|
|
8
|
+
* partway left the store half-applied.
|
|
9
|
+
*/
|
|
10
|
+
import { createConfigStore } from "../configStore/configStore.factory.js";
|
|
11
|
+
/**
|
|
12
|
+
* Whether an entry was written by one of the loader's sources (and so must
|
|
13
|
+
* be re-derived on reload) rather than by `set()` or `initialValues`.
|
|
14
|
+
*/
|
|
15
|
+
function isSourceEntry(entry, sourceNames) {
|
|
16
|
+
return sourceNames.has(entry.source);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Creates a staging store holding only the entries no source owns
|
|
20
|
+
* (runtime writes, initial values).
|
|
21
|
+
*/
|
|
22
|
+
export function createReloadStaging(store, sourceNames, freeze) {
|
|
23
|
+
const staging = createConfigStore({ freeze });
|
|
24
|
+
for (const entry of store.getEntries()) {
|
|
25
|
+
if (!isSourceEntry(entry, sourceNames))
|
|
26
|
+
staging.setEntry(entry);
|
|
27
|
+
}
|
|
28
|
+
return staging;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Copies a fully loaded staging store into the live store: keys the
|
|
32
|
+
* reload no longer produces are removed, the rest are written. An entry
|
|
33
|
+
* that was sensitive before the reload stays sensitive.
|
|
34
|
+
*/
|
|
35
|
+
export function commitReloadStaging(store, staging) {
|
|
36
|
+
const wasSensitive = new Set(store
|
|
37
|
+
.getEntries()
|
|
38
|
+
.filter((entry) => entry.sensitive)
|
|
39
|
+
.map((entry) => entry.key));
|
|
40
|
+
for (const key of store.keys()) {
|
|
41
|
+
if (!staging.has(key))
|
|
42
|
+
store.delete(key);
|
|
43
|
+
}
|
|
44
|
+
for (const entry of staging.getEntries()) {
|
|
45
|
+
store.setEntry(wasSensitive.has(entry.key) ? { ...entry, sensitive: true } : entry);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=configLoader.reload.js.map
|
|
@@ -6,6 +6,7 @@ import { createConfigStore } from "../configStore/configStore.factory.js";
|
|
|
6
6
|
import { createConfigResolver } from "../configResolver/core/configResolver.factory.js";
|
|
7
7
|
import { ConfigManagerState } from "./configManager.type.js";
|
|
8
8
|
import { ConfigManagerValidationError } from "./configManager.error.js";
|
|
9
|
+
import { collectSecretPaths, markSecretEntries, } from "./configManager.secrets.js";
|
|
9
10
|
/**
|
|
10
11
|
* Central configuration lifecycle manager.
|
|
11
12
|
*
|
|
@@ -211,23 +212,9 @@ export class ConfigManager {
|
|
|
211
212
|
if (!result.valid) {
|
|
212
213
|
throw new ConfigManagerValidationError(result.issues);
|
|
213
214
|
}
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
if (!propertySchema.secret) {
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
const entry = this.store.getEntry(key);
|
|
221
|
-
if (entry && !entry.sensitive) {
|
|
222
|
-
this.store.set(key, entry.value, {
|
|
223
|
-
source: entry.source,
|
|
224
|
-
sourceType: entry.sourceType,
|
|
225
|
-
priority: entry.priority,
|
|
226
|
-
sensitive: true,
|
|
227
|
-
resolved: entry.resolved,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
}
|
|
215
|
+
// Schemas flagged `secret` at any depth mark the store entries that
|
|
216
|
+
// hold them as sensitive, so safe serialization redacts them.
|
|
217
|
+
markSecretEntries(this.store, collectSecretPaths(schema.properties));
|
|
231
218
|
return cloneConfigValue(result.value);
|
|
232
219
|
}
|
|
233
220
|
/**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret propagation from a validation schema onto store entries.
|
|
3
|
+
*
|
|
4
|
+
* `validate()` used to look only at the top level of `schema.properties`,
|
|
5
|
+
* so `db.properties.password.secret` — or a `"db.password"` property when
|
|
6
|
+
* the source supplied a nested `db` object — never marked anything
|
|
7
|
+
* sensitive, and `toSafeObject()` printed the password.
|
|
8
|
+
*/
|
|
9
|
+
import type { AnyConfigSchema } from "../configSchema/index.js";
|
|
10
|
+
import type { ConfigStore } from "../configStore/configStore.core.js";
|
|
11
|
+
/**
|
|
12
|
+
* Every path (as dotted-key segments) at which a schema declares
|
|
13
|
+
* `secret: true`, including inside nested object and array schemas.
|
|
14
|
+
* Array items contribute the path of the array itself.
|
|
15
|
+
*/
|
|
16
|
+
export declare function collectSecretPaths(properties: Readonly<Record<string, AnyConfigSchema>>): string[][];
|
|
17
|
+
/**
|
|
18
|
+
* Marks sensitive every store entry that holds a secret path.
|
|
19
|
+
*
|
|
20
|
+
* A path is owned by the entry whose key is its longest dotted prefix, so
|
|
21
|
+
* both a flat `db.password` entry and a nested `db` object entry are found.
|
|
22
|
+
* When the secret sits inside an entry's value the whole entry is marked,
|
|
23
|
+
* since redaction works per entry. A path with no owning entry holds no
|
|
24
|
+
* value, so there is nothing to redact.
|
|
25
|
+
*/
|
|
26
|
+
export declare function markSecretEntries(store: ConfigStore, paths: readonly (readonly string[])[]): void;
|
|
27
|
+
//# sourceMappingURL=configManager.secrets.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret propagation from a validation schema onto store entries.
|
|
3
|
+
*
|
|
4
|
+
* `validate()` used to look only at the top level of `schema.properties`,
|
|
5
|
+
* so `db.properties.password.secret` — or a `"db.password"` property when
|
|
6
|
+
* the source supplied a nested `db` object — never marked anything
|
|
7
|
+
* sensitive, and `toSafeObject()` printed the password.
|
|
8
|
+
*/
|
|
9
|
+
/** Nesting bound for the schema walk. */
|
|
10
|
+
const MAX_SCHEMA_DEPTH = 32;
|
|
11
|
+
/**
|
|
12
|
+
* Every path (as dotted-key segments) at which a schema declares
|
|
13
|
+
* `secret: true`, including inside nested object and array schemas.
|
|
14
|
+
* Array items contribute the path of the array itself.
|
|
15
|
+
*/
|
|
16
|
+
export function collectSecretPaths(properties) {
|
|
17
|
+
const paths = [];
|
|
18
|
+
const walk = (schema, path, depth) => {
|
|
19
|
+
if (schema.secret) {
|
|
20
|
+
paths.push(path);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (depth >= MAX_SCHEMA_DEPTH)
|
|
24
|
+
return;
|
|
25
|
+
const nested = schema;
|
|
26
|
+
for (const [key, child] of Object.entries(nested.properties ?? {})) {
|
|
27
|
+
walk(child, [...path, ...key.split(".")], depth + 1);
|
|
28
|
+
}
|
|
29
|
+
if (nested.items)
|
|
30
|
+
walk(nested.items, path, depth + 1);
|
|
31
|
+
if (typeof nested.additionalProperties === "object" &&
|
|
32
|
+
nested.additionalProperties.secret) {
|
|
33
|
+
paths.push(path);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
for (const [key, schema] of Object.entries(properties)) {
|
|
37
|
+
walk(schema, key.split("."), 1);
|
|
38
|
+
}
|
|
39
|
+
return paths;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Marks sensitive every store entry that holds a secret path.
|
|
43
|
+
*
|
|
44
|
+
* A path is owned by the entry whose key is its longest dotted prefix, so
|
|
45
|
+
* both a flat `db.password` entry and a nested `db` object entry are found.
|
|
46
|
+
* When the secret sits inside an entry's value the whole entry is marked,
|
|
47
|
+
* since redaction works per entry. A path with no owning entry holds no
|
|
48
|
+
* value, so there is nothing to redact.
|
|
49
|
+
*/
|
|
50
|
+
export function markSecretEntries(store, paths) {
|
|
51
|
+
for (const path of paths) {
|
|
52
|
+
for (let length = path.length; length > 0; length--) {
|
|
53
|
+
const entry = store.getEntry(path.slice(0, length).join("."));
|
|
54
|
+
if (!entry)
|
|
55
|
+
continue;
|
|
56
|
+
if (!entry.sensitive) {
|
|
57
|
+
store.set(entry.key, entry.value, {
|
|
58
|
+
source: entry.source,
|
|
59
|
+
sourceType: entry.sourceType,
|
|
60
|
+
priority: entry.priority,
|
|
61
|
+
sensitive: true,
|
|
62
|
+
resolved: entry.resolved,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=configManager.secrets.js.map
|
|
@@ -42,11 +42,13 @@ export interface EnvironmentConfigSourceOptions {
|
|
|
42
42
|
readonly keyMapper?: (name: string) => string;
|
|
43
43
|
/**
|
|
44
44
|
* Decides whether a variable holds a secret. Receives the ORIGINAL
|
|
45
|
-
* variable name and the derived configuration key. Defaults to
|
|
46
|
-
*
|
|
45
|
+
* variable name and the derived configuration key. Defaults to
|
|
46
|
+
* `isSensitiveConfigKey` (passwords, tokens, API/private keys,
|
|
47
|
+
* credentials, DSNs, database URLs, `*_KEY`).
|
|
47
48
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
49
|
+
* Returning false does not un-redact: the loader still marks keys and
|
|
50
|
+
* values that `isSensitiveConfigEntry` flags (for example a URL with
|
|
51
|
+
* embedded `user:password@`).
|
|
50
52
|
*/
|
|
51
53
|
readonly isSensitive?: (name: string, key: string) => boolean;
|
|
52
54
|
}
|
|
@@ -7,14 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { defineConfigProperty } from "../configValue/configValue.core.js";
|
|
9
9
|
import { ConfigSourceType, createConfigSource } from "./configSource.core.js";
|
|
10
|
-
|
|
11
|
-
* Environment variable names whose values are treated as secrets.
|
|
12
|
-
*
|
|
13
|
-
* Matching entries are reported through `sensitiveKeys`, so
|
|
14
|
-
* `toSafeObject()` and safe entry serialization redact them instead of
|
|
15
|
-
* printing credentials into logs.
|
|
16
|
-
*/
|
|
17
|
-
const DEFAULT_SECRET_PATTERN = /(pass(word)?|secret|token|api[_.-]?key|private[_.-]?key|credential|auth)/i;
|
|
10
|
+
import { isSensitiveConfigKey } from "./configSource.sensitive.js";
|
|
18
11
|
/**
|
|
19
12
|
* Maps an environment variable name to a configuration key.
|
|
20
13
|
*/
|
|
@@ -37,7 +30,7 @@ export function createEnvironmentConfigSource(options = {}) {
|
|
|
37
30
|
const prefix = options.prefix ?? "";
|
|
38
31
|
const keyMapper = options.keyMapper ?? defaultKeyMapper;
|
|
39
32
|
const isSensitive = options.isSensitive ??
|
|
40
|
-
((name) =>
|
|
33
|
+
((name, key) => isSensitiveConfigKey(name) || isSensitiveConfigKey(key));
|
|
41
34
|
const name = options.name ?? "environment";
|
|
42
35
|
return createConfigSource({
|
|
43
36
|
name,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Name- and value-based secret detection shared by every configuration
|
|
3
|
+
* source.
|
|
4
|
+
*
|
|
5
|
+
* Only the environment source used to redact by name, so a `password` or
|
|
6
|
+
* `api_key` supplied by a memory, defaults or custom source was printed in
|
|
7
|
+
* clear by `toSafeObject()`, and even the environment pattern missed
|
|
8
|
+
* `DATABASE_URL`-style connection strings and `*_KEY` names.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Returns whether a configuration key names a secret (passwords, tokens,
|
|
12
|
+
* API/private keys, credentials, DSNs, database URLs, `*_key`).
|
|
13
|
+
*/
|
|
14
|
+
export declare function isSensitiveConfigKey(key: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Returns whether a configuration value holds a secret: a string URL with
|
|
17
|
+
* embedded `user:password@` credentials, or a nested object with a key that
|
|
18
|
+
* {@link isSensitiveConfigKey} flags anywhere inside it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isSensitiveConfigValue(value: unknown): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Whether an entry loaded under `key` with `value` must be marked
|
|
23
|
+
* sensitive.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isSensitiveConfigEntry(key: string, value: unknown): boolean;
|
|
26
|
+
//# sourceMappingURL=configSource.sensitive.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Name- and value-based secret detection shared by every configuration
|
|
3
|
+
* source.
|
|
4
|
+
*
|
|
5
|
+
* Only the environment source used to redact by name, so a `password` or
|
|
6
|
+
* `api_key` supplied by a memory, defaults or custom source was printed in
|
|
7
|
+
* clear by `toSafeObject()`, and even the environment pattern missed
|
|
8
|
+
* `DATABASE_URL`-style connection strings and `*_KEY` names.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Key names treated as secrets. Matched case-insensitively against the
|
|
12
|
+
* whole dotted key, so any path segment can trigger it.
|
|
13
|
+
*/
|
|
14
|
+
const SENSITIVE_KEY_PATTERN = /(pass(word|wd)?|secret|token|api[_.-]?key|private[_.-]?key|credential|auth|dsn|database[_.-]?url|connection[_.-]?string|(^|[_.-])key$|(^|[_.-])key[_.-])/i;
|
|
15
|
+
/** `scheme://user:password@host` — a URL with embedded credentials. */
|
|
16
|
+
const CREDENTIAL_URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\/[^\s/@:]*:[^\s/@]*@/i;
|
|
17
|
+
/** Nesting bound for {@link isSensitiveConfigValue}. */
|
|
18
|
+
const MAX_SCAN_DEPTH = 32;
|
|
19
|
+
/**
|
|
20
|
+
* Returns whether a configuration key names a secret (passwords, tokens,
|
|
21
|
+
* API/private keys, credentials, DSNs, database URLs, `*_key`).
|
|
22
|
+
*/
|
|
23
|
+
export function isSensitiveConfigKey(key) {
|
|
24
|
+
return SENSITIVE_KEY_PATTERN.test(key);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Returns whether a configuration value holds a secret: a string URL with
|
|
28
|
+
* embedded `user:password@` credentials, or a nested object with a key that
|
|
29
|
+
* {@link isSensitiveConfigKey} flags anywhere inside it.
|
|
30
|
+
*/
|
|
31
|
+
export function isSensitiveConfigValue(value) {
|
|
32
|
+
const pending = [
|
|
33
|
+
{ value, depth: 0 },
|
|
34
|
+
];
|
|
35
|
+
while (pending.length > 0) {
|
|
36
|
+
const next = pending.pop();
|
|
37
|
+
const current = next.value;
|
|
38
|
+
if (typeof current === "string") {
|
|
39
|
+
if (CREDENTIAL_URL_PATTERN.test(current))
|
|
40
|
+
return true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (typeof current !== "object" || current === null)
|
|
44
|
+
continue;
|
|
45
|
+
if (next.depth >= MAX_SCAN_DEPTH || current instanceof Date)
|
|
46
|
+
continue;
|
|
47
|
+
const entries = Array.isArray(current)
|
|
48
|
+
? current.map((item, index) => [String(index), item])
|
|
49
|
+
: Object.entries(current);
|
|
50
|
+
for (const [key, child] of entries) {
|
|
51
|
+
if (!Array.isArray(current) && isSensitiveConfigKey(key))
|
|
52
|
+
return true;
|
|
53
|
+
pending.push({ value: child, depth: next.depth + 1 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether an entry loaded under `key` with `value` must be marked
|
|
60
|
+
* sensitive.
|
|
61
|
+
*/
|
|
62
|
+
export function isSensitiveConfigEntry(key, value) {
|
|
63
|
+
return isSensitiveConfigKey(key) || isSensitiveConfigValue(value);
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=configSource.sensitive.js.map
|
|
@@ -30,6 +30,18 @@ export type ResolvedConfigValue = string | number | boolean | bigint | null | un
|
|
|
30
30
|
};
|
|
31
31
|
/**
|
|
32
32
|
* Checks whether an object key is unsafe to copy onto plain objects.
|
|
33
|
+
*
|
|
34
|
+
* Assigning these keys (most notably "__proto__") on a plain object
|
|
35
|
+
* mutates its prototype instead of creating an own property, which
|
|
36
|
+
* enables prototype-pollution attacks via untrusted configuration
|
|
37
|
+
* payloads such as JSON.parse output. The key set is
|
|
38
|
+
* `SCHEMA_FORBIDDEN_KEYS` from `@zudojs/constants`, shared with
|
|
39
|
+
* `@zudojs/schema`, `@zudojs/validation` and `@zudojs/cache`.
|
|
40
|
+
*
|
|
41
|
+
* The package itself never needs this check to stay safe: every key
|
|
42
|
+
* write goes through {@link defineConfigProperty}, which keeps such keys
|
|
43
|
+
* as inert own properties. Use it when copying configuration into code
|
|
44
|
+
* that assigns with `target[key] = value` or `Object.assign`.
|
|
33
45
|
*/
|
|
34
46
|
export declare function isUnsafeConfigKey(key: string): boolean;
|
|
35
47
|
/**
|
|
@@ -72,7 +84,8 @@ export declare function parseConfigString(value: string | undefined): ConfigPrim
|
|
|
72
84
|
*/
|
|
73
85
|
export declare function parseConfigBoolean(value: string | boolean | undefined): boolean | undefined;
|
|
74
86
|
/**
|
|
75
|
-
* Parses a numeric configuration value.
|
|
87
|
+
* Parses a numeric configuration value. Only decimal notation is
|
|
88
|
+
* accepted; hex, binary and octal strings return `undefined`.
|
|
76
89
|
*/
|
|
77
90
|
export declare function parseConfigNumber(value: string | number | undefined): number | undefined;
|
|
78
91
|
/**
|
|
@@ -6,24 +6,24 @@
|
|
|
6
6
|
* possible, while allowing common runtime values such as undefined,
|
|
7
7
|
* Date, and Buffer-like binary data to be handled safely.
|
|
8
8
|
*/
|
|
9
|
+
import { SCHEMA_FORBIDDEN_KEYS } from "@zudojs/constants";
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
+
* Checks whether an object key is unsafe to copy onto plain objects.
|
|
11
12
|
*
|
|
12
13
|
* Assigning these keys (most notably "__proto__") on a plain object
|
|
13
14
|
* mutates its prototype instead of creating an own property, which
|
|
14
15
|
* enables prototype-pollution attacks via untrusted configuration
|
|
15
|
-
* payloads such as JSON.parse output.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* Checks whether an object key is unsafe to copy onto plain objects.
|
|
16
|
+
* payloads such as JSON.parse output. The key set is
|
|
17
|
+
* `SCHEMA_FORBIDDEN_KEYS` from `@zudojs/constants`, shared with
|
|
18
|
+
* `@zudojs/schema`, `@zudojs/validation` and `@zudojs/cache`.
|
|
19
|
+
*
|
|
20
|
+
* The package itself never needs this check to stay safe: every key
|
|
21
|
+
* write goes through {@link defineConfigProperty}, which keeps such keys
|
|
22
|
+
* as inert own properties. Use it when copying configuration into code
|
|
23
|
+
* that assigns with `target[key] = value` or `Object.assign`.
|
|
24
24
|
*/
|
|
25
25
|
export function isUnsafeConfigKey(key) {
|
|
26
|
-
return
|
|
26
|
+
return SCHEMA_FORBIDDEN_KEYS.has(key);
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Checks whether a value is a configuration primitive.
|
|
@@ -103,8 +103,10 @@ export function toConfigJsonValue(value) {
|
|
|
103
103
|
const result = {};
|
|
104
104
|
for (const [key, child] of Object.entries(value)) {
|
|
105
105
|
const converted = toConfigJsonValue(child);
|
|
106
|
+
// Defined, never assigned: an own "__proto__" key from a JSON or
|
|
107
|
+
// remote source would otherwise replace the result's prototype.
|
|
106
108
|
if (converted !== undefined) {
|
|
107
|
-
result
|
|
109
|
+
defineConfigProperty(result, key, converted);
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
return result;
|
|
@@ -184,8 +186,11 @@ export function parseConfigBoolean(value) {
|
|
|
184
186
|
return undefined;
|
|
185
187
|
}
|
|
186
188
|
}
|
|
189
|
+
/** Optionally signed decimal number, with optional fraction and exponent. */
|
|
190
|
+
const DECIMAL_NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?$/i;
|
|
187
191
|
/**
|
|
188
|
-
* Parses a numeric configuration value.
|
|
192
|
+
* Parses a numeric configuration value. Only decimal notation is
|
|
193
|
+
* accepted; hex, binary and octal strings return `undefined`.
|
|
189
194
|
*/
|
|
190
195
|
export function parseConfigNumber(value) {
|
|
191
196
|
if (value === undefined) {
|
|
@@ -198,6 +203,11 @@ export function parseConfigNumber(value) {
|
|
|
198
203
|
if (normalized.length === 0) {
|
|
199
204
|
return undefined;
|
|
200
205
|
}
|
|
206
|
+
// Decimal only. `Number()` also accepts "0x1F90", "0b11" and "0o17",
|
|
207
|
+
// so a port of "0x1F90" silently became 8080.
|
|
208
|
+
if (!DECIMAL_NUMBER.test(normalized)) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
201
211
|
const parsed = Number(normalized);
|
|
202
212
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
203
213
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/config",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Layered configuration management with multiple sources, validation, and environment-specific overrides.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"node": ">=24.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@zudojs/
|
|
31
|
+
"@zudojs/constants": "1.1.0",
|
|
32
|
+
"@zudojs/errors": "1.1.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"typescript": "7.0.2",
|