@warlock.js/cache 4.15.0 → 5.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 +21 -0
- package/cjs/index.cjs +117 -14
- package/cjs/index.cjs.map +1 -1
- package/esm/drivers/base-cache-driver.d.mts +5 -0
- package/esm/drivers/base-cache-driver.d.mts.map +1 -1
- package/esm/drivers/base-cache-driver.mjs +8 -4
- package/esm/drivers/base-cache-driver.mjs.map +1 -1
- package/esm/drivers/file-cache-driver.d.mts +38 -0
- package/esm/drivers/file-cache-driver.d.mts.map +1 -1
- package/esm/drivers/file-cache-driver.mjs +69 -7
- package/esm/drivers/file-cache-driver.mjs.map +1 -1
- package/esm/drivers/redis-cache-driver.d.mts.map +1 -1
- package/esm/drivers/redis-cache-driver.mjs +12 -6
- package/esm/drivers/redis-cache-driver.mjs.map +1 -1
- package/esm/index.d.mts +2 -2
- package/esm/index.mjs +2 -2
- package/esm/utils.d.mts +17 -1
- package/esm/utils.d.mts.map +1 -1
- package/esm/utils.mjs +31 -1
- package/esm/utils.mjs.map +1 -1
- package/llms-full.txt +10 -1
- package/package.json +4 -4
- package/skills/handle-cache-errors/SKILL.md +1 -0
- package/skills/pick-cache-driver/SKILL.md +8 -0
- package/skills/use-cache-namespace/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to `@warlock.js/cache` are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
|
|
6
6
|
|
|
7
|
+
## 5.0.0 - 2026-08-25
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- This package is unchanged in 5.0.0; its version moved only because the Warlock family releases in lockstep.
|
|
12
|
+
|
|
13
|
+
## 4.16.0 - 2026-08-18
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
|
|
17
|
+
- **File driver path traversal (Critical):** cache keys were mapped to on-disk paths with `path.resolve(directory, key)` and no sanitization, so a key containing `../` (reachable through `set`/`get`/`remove`/`removeNamespace`, including keys derived from user input via `cached()` auto-keys) escaped the cache directory and allowed arbitrary file read, write, and recursive delete. The file driver now percent-encodes `%`, `/`, and `\` when mapping a key to its directory (each key becomes exactly one contained directory component; the logical `.`-delimited namespace scheme is unchanged) and additionally asserts the resolved path stays inside the cache root, throwing `CacheError` otherwise. Memory/redis/pg key semantics are unaffected.
|
|
18
|
+
- Redis `removeNamespace` now escapes glob metacharacters (`*`, `?`, `[`, `\`) before building its `KEYS` pattern, so a namespace carrying untrusted input can no longer widen the match and delete keys outside its own prefix.
|
|
19
|
+
- Removed the raw `console.log(value)` dump of the full cached payload when `structuredClone` fails in `parseCachedData` — cached values (potentially PII/tokens) no longer leak to stdout; the structured error log with the value's type is kept.
|
|
20
|
+
- **Credential leak via error logging (Medium):** `logError()` and the Redis driver's `connect()` failure path printed the raw `Error` object straight to stdout (`console.log`) or to `log.fatal`, which could include the connection URL — and password — that some Redis/Node client errors echo back in `error.message`/`cause` on connection failure. Both call sites now go through a new `safeErrorInfo()` helper that logs only a redacted `{ message, code }` shape (never the raw error object), with any `scheme://user:pass@` credentials in the message masked to `scheme://[REDACTED]@`. The bare `console.log(error)`/`console.log("Err", error)` calls are gone entirely.
|
|
21
|
+
- **Redis `removeNamespace` blocking `KEYS` scan (Medium):** replaced the blocking `KEYS` command with a non-blocking `SCAN` cursor loop (`client.scanIterator`), so clearing a namespace on a large keyspace no longer stalls the single-threaded Redis event loop for every other tenant/consumer. The existing glob-escaping fix (above) is unchanged.
|
|
22
|
+
- **File driver `removeNamespace` dotted-key gap (Medium):** dotted keys (`ns.a`) are stored as *sibling* directories under the cache root (see the path-traversal fix above), so removing namespace `ns` — which only ever deleted a directory literally named `ns` — silently left every `ns.*` key on disk. `removeNamespace` now lists the cache root's immediate children, decodes each back to its logical key, and removes every directory whose logical key equals the namespace or starts with `<namespace>.`, matching the boundary semantics the `pg` driver already used for the same contract. Honors `globalPrefix` (previously ignored, so a global flush could wipe the whole cache root instead of scoping to the tenant) and preserves the existing path-containment guard.
|
|
23
|
+
|
|
24
|
+
### Dependencies
|
|
25
|
+
|
|
26
|
+
- Bumped `@mongez/reinforcements` to `^4.0.1`. The major makes `Random.string/nanoid/id/token/uuid` CSPRNG-backed (WebCrypto) and removes `Random.seed()` support; audited this package's source and tests for `Random.seed(` and for seeded/reproducible use of `Random.*`, no hits, so no code changes were needed.
|
|
27
|
+
|
|
7
28
|
## 4.2.11
|
|
8
29
|
|
|
9
30
|
### Changed
|
package/cjs/index.cjs
CHANGED
|
@@ -460,6 +460,36 @@ function cosineSimilarity(a, b) {
|
|
|
460
460
|
if (normA === 0 || normB === 0) return 0;
|
|
461
461
|
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
462
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Matches userinfo credentials embedded in a connection URL
|
|
465
|
+
* (e.g. `redis://user:pass@host`). Node/Redis/Postgres client errors
|
|
466
|
+
* frequently echo the target connection string — including the password —
|
|
467
|
+
* back in `error.message` on connection failure.
|
|
468
|
+
*/
|
|
469
|
+
const CREDENTIALS_IN_URL = /(:\/\/)[^\s/@]+:[^\s/@]+@/g;
|
|
470
|
+
/**
|
|
471
|
+
* Strip embedded `user:pass@` credentials from a string before it reaches
|
|
472
|
+
* any log sink.
|
|
473
|
+
*/
|
|
474
|
+
function redactCredentials(message) {
|
|
475
|
+
return message.replace(CREDENTIALS_IN_URL, "$1[REDACTED]@");
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Reduce an unknown thrown value to a log-safe `{ message, code? }` shape.
|
|
479
|
+
* Callers must never log the raw error object itself — it may carry the
|
|
480
|
+
* connection URL (with password) in the top-level message, `cause`, or
|
|
481
|
+
* driver-specific fields, all of which `console.log`/`util.inspect` would
|
|
482
|
+
* still print in full.
|
|
483
|
+
*/
|
|
484
|
+
function safeErrorInfo(error) {
|
|
485
|
+
if (error instanceof Error) {
|
|
486
|
+
const info = { message: redactCredentials(error.message) };
|
|
487
|
+
const code = error.code;
|
|
488
|
+
if (code) info.code = String(code);
|
|
489
|
+
return info;
|
|
490
|
+
}
|
|
491
|
+
return { message: redactCredentials(String(error)) };
|
|
492
|
+
}
|
|
463
493
|
let CACHE_FOR = /* @__PURE__ */ function(CACHE_FOR) {
|
|
464
494
|
/**
|
|
465
495
|
* Cache for 30 Minutes (in seconds)
|
|
@@ -2088,10 +2118,15 @@ var BaseCacheDriver = class {
|
|
|
2088
2118
|
}
|
|
2089
2119
|
/**
|
|
2090
2120
|
* Log error message
|
|
2121
|
+
*
|
|
2122
|
+
* Never logs the raw `error` object — driver connection errors (Redis,
|
|
2123
|
+
* Postgres, ...) can carry the connection string, including the password,
|
|
2124
|
+
* in the message/cause. Only a redacted `{ message, code }` shape is
|
|
2125
|
+
* passed to the structured logger.
|
|
2091
2126
|
*/
|
|
2092
2127
|
logError(message, error) {
|
|
2093
|
-
_warlock_js_logger.log.error("cache." + this.name, "error", message);
|
|
2094
|
-
|
|
2128
|
+
if (error) _warlock_js_logger.log.error("cache." + this.name, "error", message, safeErrorInfo(error));
|
|
2129
|
+
else _warlock_js_logger.log.error("cache." + this.name, "error", message);
|
|
2095
2130
|
}
|
|
2096
2131
|
/**
|
|
2097
2132
|
* Get the default TTL in seconds. Parses human-readable strings (`"1h"`, `"30m"`)
|
|
@@ -2137,7 +2172,6 @@ var BaseCacheDriver = class {
|
|
|
2137
2172
|
try {
|
|
2138
2173
|
return structuredClone(value);
|
|
2139
2174
|
} catch (error) {
|
|
2140
|
-
console.log(value);
|
|
2141
2175
|
this.logError(`Failed to clone cached value for ${key}, typeof value: ${typeof value}`, error);
|
|
2142
2176
|
throw error;
|
|
2143
2177
|
}
|
|
@@ -2293,12 +2327,74 @@ var FileCacheDriver = class extends BaseCacheDriver {
|
|
|
2293
2327
|
return "cache.json";
|
|
2294
2328
|
}
|
|
2295
2329
|
/**
|
|
2330
|
+
* Map a parsed cache key (or namespace) to its on-disk directory.
|
|
2331
|
+
*
|
|
2332
|
+
* The key must become exactly one directory component: `%` and the path
|
|
2333
|
+
* separators are percent-encoded so a hostile key (`../../etc`,
|
|
2334
|
+
* `..\\..\\evil`) turns into an inert directory name instead of a path
|
|
2335
|
+
* traversal, while distinct keys can never collide after encoding. Dots are
|
|
2336
|
+
* left untouched — the `.`-delimited namespace scheme is purely logical and
|
|
2337
|
+
* only ever produces a single filesystem component here.
|
|
2338
|
+
*/
|
|
2339
|
+
keyDirectory(parsedKey) {
|
|
2340
|
+
return this.containedPath(this.encodeKeySegment(parsedKey));
|
|
2341
|
+
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Percent-encode a parsed key into an inert, single-component directory
|
|
2344
|
+
* name. Dots are left untouched — see {@link keyDirectory}.
|
|
2345
|
+
*/
|
|
2346
|
+
encodeKeySegment(parsedKey) {
|
|
2347
|
+
return parsedKey.replace(/%/g, "%25").replace(/\//g, "%2F").replace(/\\/g, "%5C");
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Reverse of {@link encodeKeySegment} — recovers the logical, dot-delimited
|
|
2351
|
+
* parsed key from an on-disk directory name. Used by {@link removeNamespace}
|
|
2352
|
+
* to test which sibling directories logically belong to a namespace, since
|
|
2353
|
+
* dotted keys (`ns.a`) are stored as sibling directories rather than nested
|
|
2354
|
+
* ones (see {@link keyDirectory}'s doc comment).
|
|
2355
|
+
*/
|
|
2356
|
+
decodeKeySegment(encoded) {
|
|
2357
|
+
return encoded.replace(/%2F/g, "/").replace(/%5C/g, "\\").replace(/%25/g, "%");
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Resolve `segment` against the cache root and throw when the result lands
|
|
2361
|
+
* outside it — the last line of defense against path traversal, independent
|
|
2362
|
+
* of how the key was encoded.
|
|
2363
|
+
*/
|
|
2364
|
+
containedPath(segment) {
|
|
2365
|
+
const base = path.default.resolve(this.directory);
|
|
2366
|
+
const resolved = path.default.resolve(base, segment);
|
|
2367
|
+
const relative = path.default.relative(base, resolved);
|
|
2368
|
+
if (relative === ".." || relative.startsWith(`..${path.default.sep}`) || path.default.isAbsolute(relative)) throw new CacheError(`Cache key resolves outside the cache directory: "${segment}"`);
|
|
2369
|
+
return resolved;
|
|
2370
|
+
}
|
|
2371
|
+
/**
|
|
2296
2372
|
* {@inheritdoc}
|
|
2373
|
+
*
|
|
2374
|
+
* Dotted keys (`ns.a`) are stored as sibling directories, not nested ones
|
|
2375
|
+
* (see {@link keyDirectory}) — a directory literally named `<namespace>`
|
|
2376
|
+
* rarely exists on its own. Namespace removal therefore has to scan the
|
|
2377
|
+
* cache root's immediate children and remove every directory whose
|
|
2378
|
+
* *logical* (decoded) name equals the namespace or starts with
|
|
2379
|
+
* `<namespace>.`, mirroring the `key = $1 OR key LIKE $2` boundary
|
|
2380
|
+
* semantics the `pg` driver already uses for the same contract.
|
|
2297
2381
|
*/
|
|
2298
2382
|
async removeNamespace(namespace) {
|
|
2383
|
+
const parsedNamespace = this.parseKey(namespace);
|
|
2299
2384
|
this.log("clearing", namespace);
|
|
2300
2385
|
try {
|
|
2301
|
-
|
|
2386
|
+
const root = this.containedPath("");
|
|
2387
|
+
if (parsedNamespace === "") {
|
|
2388
|
+
await (0, _warlock_js_fs.removeDirectoryAsync)(root);
|
|
2389
|
+
this.log("cleared", namespace);
|
|
2390
|
+
return this;
|
|
2391
|
+
}
|
|
2392
|
+
const prefix = `${parsedNamespace}.`;
|
|
2393
|
+
const entries = await (0, _warlock_js_fs.listDirectoriesAsync)(root).catch(() => []);
|
|
2394
|
+
await Promise.all(entries.map(async (entryPath) => {
|
|
2395
|
+
const decoded = this.decodeKeySegment(path.default.basename(entryPath));
|
|
2396
|
+
if (decoded === parsedNamespace || decoded.startsWith(prefix)) await (0, _warlock_js_fs.removeDirectoryAsync)(entryPath);
|
|
2397
|
+
}));
|
|
2302
2398
|
this.log("cleared", namespace);
|
|
2303
2399
|
} catch (error) {}
|
|
2304
2400
|
return this;
|
|
@@ -2322,7 +2418,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
|
|
|
2322
2418
|
existing: null
|
|
2323
2419
|
};
|
|
2324
2420
|
const data = this.prepareDataForStorage(value, ttl, staleAt);
|
|
2325
|
-
const fileDirectory =
|
|
2421
|
+
const fileDirectory = this.keyDirectory(parsedKey);
|
|
2326
2422
|
await (0, _warlock_js_fs.ensureDirectoryAsync)(fileDirectory);
|
|
2327
2423
|
await (0, _warlock_js_fs.putJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName), data);
|
|
2328
2424
|
if (tags && tags.length > 0) await this.applyTags(parsedKey, tags);
|
|
@@ -2362,7 +2458,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
|
|
|
2362
2458
|
*/
|
|
2363
2459
|
async getEntry(key) {
|
|
2364
2460
|
const parsedKey = this.parseKey(key);
|
|
2365
|
-
const fileDirectory =
|
|
2461
|
+
const fileDirectory = this.keyDirectory(parsedKey);
|
|
2366
2462
|
try {
|
|
2367
2463
|
const entry = await (0, _warlock_js_fs.getJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName));
|
|
2368
2464
|
if (!entry) return null;
|
|
@@ -2378,7 +2474,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
|
|
|
2378
2474
|
async get(key) {
|
|
2379
2475
|
const parsedKey = this.parseKey(key);
|
|
2380
2476
|
this.log("fetching", parsedKey);
|
|
2381
|
-
const fileDirectory =
|
|
2477
|
+
const fileDirectory = this.keyDirectory(parsedKey);
|
|
2382
2478
|
try {
|
|
2383
2479
|
const value = await (0, _warlock_js_fs.getJsonFileAsync)(path.default.resolve(fileDirectory, this.fileName));
|
|
2384
2480
|
const result = await this.parseCachedData(parsedKey, value);
|
|
@@ -2401,7 +2497,7 @@ var FileCacheDriver = class extends BaseCacheDriver {
|
|
|
2401
2497
|
async remove(key) {
|
|
2402
2498
|
const parsedKey = this.parseKey(key);
|
|
2403
2499
|
this.log("removing", parsedKey);
|
|
2404
|
-
const fileDirectory =
|
|
2500
|
+
const fileDirectory = this.keyDirectory(parsedKey);
|
|
2405
2501
|
try {
|
|
2406
2502
|
await (0, _warlock_js_fs.removeDirectoryAsync)(fileDirectory);
|
|
2407
2503
|
this.log("removed", parsedKey);
|
|
@@ -3796,8 +3892,13 @@ var RedisCacheDriver = class extends BaseCacheDriver {
|
|
|
3796
3892
|
async removeNamespace(namespace) {
|
|
3797
3893
|
namespace = this.parseKey(namespace);
|
|
3798
3894
|
this.log("clearing", namespace);
|
|
3799
|
-
const
|
|
3800
|
-
|
|
3895
|
+
const pattern = namespace.replace(/[\\*?[\]]/g, "\\$&");
|
|
3896
|
+
const keys = [];
|
|
3897
|
+
if (this.client) for await (const key of this.client.scanIterator({
|
|
3898
|
+
MATCH: `${pattern}*`,
|
|
3899
|
+
COUNT: 100
|
|
3900
|
+
})) keys.push(key);
|
|
3901
|
+
if (keys.length === 0) {
|
|
3801
3902
|
this.log("notFound", namespace);
|
|
3802
3903
|
return;
|
|
3803
3904
|
}
|
|
@@ -3967,15 +4068,15 @@ var RedisCacheDriver = class extends BaseCacheDriver {
|
|
|
3967
4068
|
const { createClient } = RedisClient;
|
|
3968
4069
|
this.client = createClient(clientOptions);
|
|
3969
4070
|
this.client.on("error", (error) => {
|
|
3970
|
-
|
|
3971
|
-
|
|
4071
|
+
const { message } = safeErrorInfo(error);
|
|
4072
|
+
if (error.code === "ECONNREFUSED") this.log("connectionFailed", message);
|
|
4073
|
+
else this.log("error", message);
|
|
3972
4074
|
});
|
|
3973
4075
|
await this.client.connect();
|
|
3974
4076
|
this.log("connected");
|
|
3975
4077
|
await this.emit("connected");
|
|
3976
4078
|
} catch (error) {
|
|
3977
|
-
|
|
3978
|
-
_warlock_js_logger.log.fatal("cache", "redis", error);
|
|
4079
|
+
_warlock_js_logger.log.fatal("cache", "redis", "Failed to connect", safeErrorInfo(error));
|
|
3979
4080
|
await this.emit("error", { error });
|
|
3980
4081
|
}
|
|
3981
4082
|
}
|
|
@@ -4088,5 +4189,7 @@ exports.normalizeToOptions = normalizeToOptions;
|
|
|
4088
4189
|
exports.normalizeToRememberOptions = normalizeToRememberOptions;
|
|
4089
4190
|
exports.parseCacheKey = parseCacheKey;
|
|
4090
4191
|
exports.parseTtl = parseTtl;
|
|
4192
|
+
exports.redactCredentials = redactCredentials;
|
|
4091
4193
|
exports.resolveTtl = resolveTtl;
|
|
4194
|
+
exports.safeErrorInfo = safeErrorInfo;
|
|
4092
4195
|
//# sourceMappingURL=index.cjs.map
|