@goodandready/dsh-key-rotation 0.8.72 → 0.8.73
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 +11 -0
- package/lib/balance-inspector.js +9 -1
- package/lib/client.js +18 -7
- package/lib/crypto-storage.js +25 -0
- package/lib/ops-config-io.js +104 -32
- package/lib/ops-keys.js +2 -21
- package/lib/ops-test.js +9 -1
- package/lib/pool.js +4 -3
- package/lib/selection-trace.js +13 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.73 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
### Security & Hardening (Block 1 Security Release)
|
|
6
|
+
- **Origin & Bridge Guard**: Reject unauthenticated mutating requests without Origin/Referer (#516).
|
|
7
|
+
- **Key Endpoint Protection**: Forbid plaintext credential reveal via `GET /dsh-key-rotation/key` (403 Forbidden) (#543).
|
|
8
|
+
- **Safe Export Webhook Masking**: Mask real `notifyWebhook` URLs and embedded credentials during masked config exports (#532).
|
|
9
|
+
- **Secure Export Passphrase Transport**: Passphrase for encrypted exports is now transmitted via `POST` JSON body instead of URL query string (#552).
|
|
10
|
+
- **Selection Trace Sanitization**: Redact upstream API tokens and error details before recording steps to selection trace (#537).
|
|
11
|
+
- **Fail-Closed Balance & Probe Inspection**: Prevent sending ciphertext credentials (`enc:v1:...`) upstream if decryption fails (#539).
|
|
12
|
+
- **PBKDF2 Iteration Bounds & Async KDF**: Enforce strict iteration limits (`10000..200000`) and async derivation to eliminate event-loop blocking DoS (#542).
|
|
13
|
+
|
|
3
14
|
## [0.8.72] — 2026-10-23
|
|
4
15
|
|
|
5
16
|
### Security
|
package/lib/balance-inspector.js
CHANGED
|
@@ -169,7 +169,15 @@ export async function inspectBalanceForRef(ctx, ref, provider, { testValue, fetc
|
|
|
169
169
|
let rawValue = testValue || hit?.value;
|
|
170
170
|
if (!rawValue && process.env[ref]) rawValue = process.env[ref];
|
|
171
171
|
if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
|
|
172
|
-
|
|
172
|
+
try {
|
|
173
|
+
const dec = decryptSecret(rawValue);
|
|
174
|
+
if (!dec || dec === rawValue || dec.startsWith('enc:v1:')) {
|
|
175
|
+
return { ok: false, error: 'failed to decrypt credential: missing or invalid DSH_KEY_SECRET' };
|
|
176
|
+
}
|
|
177
|
+
rawValue = dec;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return { ok: false, error: `failed to decrypt credential: ${err.message}` };
|
|
180
|
+
}
|
|
173
181
|
}
|
|
174
182
|
if (!rawValue) return { ok: false, error: 'no credential' };
|
|
175
183
|
return checkProviderBalance(provider, rawValue, { fetchImpl, timeoutMs });
|
package/lib/client.js
CHANGED
|
@@ -1276,13 +1276,24 @@ window.__ModuleLoader__.load({
|
|
|
1276
1276
|
const runExport = async () => {
|
|
1277
1277
|
setExportError('');
|
|
1278
1278
|
try {
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1281
|
-
if (
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1279
|
+
const passphrase = exportPassphrase.trim();
|
|
1280
|
+
let res;
|
|
1281
|
+
if (passphrase) {
|
|
1282
|
+
// Secure POST transmission for encrypted export (#552)
|
|
1283
|
+
res = await fetch('/dsh-key-rotation/export', {
|
|
1284
|
+
method: 'POST',
|
|
1285
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
1286
|
+
body: JSON.stringify({ masked: exportMasked, passphrase }),
|
|
1287
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1288
|
+
});
|
|
1289
|
+
} else {
|
|
1290
|
+
const q = new URLSearchParams();
|
|
1291
|
+
q.set('masked', exportMasked ? 'true' : 'false');
|
|
1292
|
+
res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
|
|
1293
|
+
headers: { accept: 'application/json' },
|
|
1294
|
+
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1286
1297
|
const data = await res.json();
|
|
1287
1298
|
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Export failed');
|
|
1288
1299
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
package/lib/crypto-storage.js
CHANGED
|
@@ -47,3 +47,28 @@ export function decryptSecret(ciphertext, secretKey = process.env.DSH_KEY_SECRET
|
|
|
47
47
|
return ciphertext;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decrypt ciphertext using AES-256-GCM.
|
|
53
|
+
* Fails closed by throwing or returning null if decryption fails or secretKey is missing.
|
|
54
|
+
*/
|
|
55
|
+
export function decryptSecretFailClosed(ciphertext, secretKey = process.env.DSH_KEY_SECRET) {
|
|
56
|
+
if (!ciphertext || typeof ciphertext !== 'string') return ciphertext;
|
|
57
|
+
if (!ciphertext.startsWith('enc:v1:')) return ciphertext; // plaintext is allowed as-is
|
|
58
|
+
if (!secretKey) {
|
|
59
|
+
throw new Error('DSH_KEY_SECRET is required to decrypt credential');
|
|
60
|
+
}
|
|
61
|
+
const parts = ciphertext.slice('enc:v1:'.length).split(':');
|
|
62
|
+
if (parts.length !== 3) {
|
|
63
|
+
throw new Error('Malformed encrypted secret payload');
|
|
64
|
+
}
|
|
65
|
+
const [ivHex, tagHex, dataHex] = parts;
|
|
66
|
+
const key = crypto.createHash('sha256').update(String(secretKey)).digest();
|
|
67
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
68
|
+
const authTag = Buffer.from(tagHex, 'hex');
|
|
69
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
70
|
+
decipher.setAuthTag(authTag);
|
|
71
|
+
let decrypted = decipher.update(dataHex, 'hex', 'utf8');
|
|
72
|
+
decrypted += decipher.final('utf8');
|
|
73
|
+
return decrypted;
|
|
74
|
+
}
|
package/lib/ops-config-io.js
CHANGED
|
@@ -12,9 +12,43 @@ import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths
|
|
|
12
12
|
import { keyTail } from './pool.js';
|
|
13
13
|
import { plainConfig } from './index.js';
|
|
14
14
|
|
|
15
|
+
export const MAX_PBKDF2_ITERATIONS = 200000;
|
|
16
|
+
export const MIN_PBKDF2_ITERATIONS = 10000;
|
|
17
|
+
export const DEFAULT_PBKDF2_ITERATIONS = 100000;
|
|
18
|
+
|
|
19
|
+
export function deriveKeyAsync(passphrase, salt, iterations) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
crypto.pbkdf2(passphrase, salt, iterations, 32, 'sha256', (err, derivedKey) => {
|
|
22
|
+
if (err) reject(err);
|
|
23
|
+
else resolve(derivedKey);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateAndExtractCryptoParams(payload) {
|
|
29
|
+
if (!payload || typeof payload !== 'object') {
|
|
30
|
+
throw new Error('Invalid encrypted config payload structure');
|
|
31
|
+
}
|
|
32
|
+
if (!payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
|
|
33
|
+
throw new Error('Missing salt, iv, tag or ciphertext in payload');
|
|
34
|
+
}
|
|
35
|
+
const iters = Number(payload.iterations) || DEFAULT_PBKDF2_ITERATIONS;
|
|
36
|
+
if (!Number.isFinite(iters) || iters < MIN_PBKDF2_ITERATIONS || iters > MAX_PBKDF2_ITERATIONS) {
|
|
37
|
+
throw new Error(`PBKDF2 iterations must be between ${MIN_PBKDF2_ITERATIONS} and ${MAX_PBKDF2_ITERATIONS}`);
|
|
38
|
+
}
|
|
39
|
+
const salt = Buffer.from(payload.salt, 'hex');
|
|
40
|
+
const iv = Buffer.from(payload.iv, 'hex');
|
|
41
|
+
const tag = Buffer.from(payload.tag, 'hex');
|
|
42
|
+
const ciphertext = Buffer.from(payload.ciphertext, 'hex');
|
|
43
|
+
if (salt.length !== 16 || iv.length !== 12 || tag.length !== 16) {
|
|
44
|
+
throw new Error('Malformed salt, iv or auth tag length in encrypted payload');
|
|
45
|
+
}
|
|
46
|
+
return { iters, salt, iv, tag, ciphertext };
|
|
47
|
+
}
|
|
48
|
+
|
|
15
49
|
export function encryptConfig(plainText, passphrase) {
|
|
16
50
|
const salt = crypto.randomBytes(16);
|
|
17
|
-
const key = crypto.pbkdf2Sync(passphrase, salt,
|
|
51
|
+
const key = crypto.pbkdf2Sync(passphrase, salt, DEFAULT_PBKDF2_ITERATIONS, 32, 'sha256');
|
|
18
52
|
const iv = crypto.randomBytes(12);
|
|
19
53
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
20
54
|
const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
|
|
@@ -23,7 +57,7 @@ export function encryptConfig(plainText, passphrase) {
|
|
|
23
57
|
encrypted: true,
|
|
24
58
|
algorithm: 'aes-256-gcm',
|
|
25
59
|
kdf: 'pbkdf2',
|
|
26
|
-
iterations:
|
|
60
|
+
iterations: DEFAULT_PBKDF2_ITERATIONS,
|
|
27
61
|
salt: salt.toString('hex'),
|
|
28
62
|
iv: iv.toString('hex'),
|
|
29
63
|
tag: tag.toString('hex'),
|
|
@@ -32,14 +66,17 @@ export function encryptConfig(plainText, passphrase) {
|
|
|
32
66
|
}
|
|
33
67
|
|
|
34
68
|
export function decryptConfig(payload, passphrase) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
69
|
+
const { iters, salt, iv, tag, ciphertext } = validateAndExtractCryptoParams(payload);
|
|
70
|
+
const key = crypto.pbkdf2Sync(passphrase, salt, iters, 32, 'sha256');
|
|
71
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
72
|
+
decipher.setAuthTag(tag);
|
|
73
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
74
|
+
return decrypted.toString('utf8');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function decryptConfigAsync(payload, passphrase) {
|
|
78
|
+
const { iters, salt, iv, tag, ciphertext } = validateAndExtractCryptoParams(payload);
|
|
79
|
+
const key = await deriveKeyAsync(passphrase, salt, iters);
|
|
43
80
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
44
81
|
decipher.setAuthTag(tag);
|
|
45
82
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
@@ -65,19 +102,30 @@ export function maskConfig(config) {
|
|
|
65
102
|
}
|
|
66
103
|
}
|
|
67
104
|
}
|
|
68
|
-
|
|
105
|
+
function maskWebhookString(val) {
|
|
106
|
+
if (!val || typeof val !== 'string') return val;
|
|
69
107
|
try {
|
|
70
|
-
const u = new URL(
|
|
108
|
+
const u = new URL(val);
|
|
109
|
+
if (u.username) u.username = '***';
|
|
110
|
+
if (u.password) u.password = '***';
|
|
111
|
+
if (u.search) u.search = '?token=***';
|
|
71
112
|
const parts = u.pathname.split('/');
|
|
72
113
|
if (parts.length > 2) {
|
|
73
114
|
parts[parts.length - 1] = '***';
|
|
74
115
|
u.pathname = parts.join('/');
|
|
75
116
|
}
|
|
76
|
-
|
|
117
|
+
return u.toString();
|
|
77
118
|
} catch (_) {
|
|
78
|
-
|
|
119
|
+
return '***';
|
|
79
120
|
}
|
|
80
121
|
}
|
|
122
|
+
|
|
123
|
+
if (clone.webhookUrl) {
|
|
124
|
+
clone.webhookUrl = maskWebhookString(clone.webhookUrl);
|
|
125
|
+
}
|
|
126
|
+
if (clone.notifyWebhook) {
|
|
127
|
+
clone.notifyWebhook = maskWebhookString(clone.notifyWebhook);
|
|
128
|
+
}
|
|
81
129
|
if (clone.webhookActionToken) clone.webhookActionToken = '***';
|
|
82
130
|
return clone;
|
|
83
131
|
}
|
|
@@ -138,13 +186,13 @@ export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
|
|
|
138
186
|
}
|
|
139
187
|
|
|
140
188
|
export function registerConfigIoRoutes(ctx, deps) {
|
|
141
|
-
// ── GET /dsh-key-rotation/export ──
|
|
189
|
+
// ── GET & POST /dsh-key-rotation/export (#552: support POST for safe passphrase transmission) ──
|
|
142
190
|
ctx.effect(() => ctx.webServer.register({
|
|
143
191
|
kind: 'exact',
|
|
144
192
|
path: CONFIG_EXPORT_PATH,
|
|
145
193
|
handler: async (req, res) => {
|
|
146
|
-
if (req.method !== 'GET') {
|
|
147
|
-
json(res, 405, { error: { code: 'method', message: 'GET only' } });
|
|
194
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
195
|
+
json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
|
|
148
196
|
return;
|
|
149
197
|
}
|
|
150
198
|
if (!isTrustedBridgeRequest(req)) {
|
|
@@ -152,10 +200,29 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
152
200
|
return;
|
|
153
201
|
}
|
|
154
202
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
203
|
+
let masked = true;
|
|
204
|
+
let passphrase = null;
|
|
205
|
+
|
|
206
|
+
if (req.method === 'POST') {
|
|
207
|
+
let body = {};
|
|
208
|
+
try {
|
|
209
|
+
body = await readJson(req);
|
|
210
|
+
} catch (_) {
|
|
211
|
+
body = {};
|
|
212
|
+
}
|
|
213
|
+
masked = body.masked !== false;
|
|
214
|
+
passphrase = typeof body.passphrase === 'string' && body.passphrase.trim() ? body.passphrase.trim() : null;
|
|
215
|
+
} else {
|
|
216
|
+
const urlObj = new URL(req.url, 'http://127.0.0.1');
|
|
217
|
+
const maskedParam = urlObj.searchParams.get('masked');
|
|
218
|
+
masked = maskedParam === null ? true : maskedParam !== 'false';
|
|
219
|
+
if (urlObj.searchParams.has('passphrase')) {
|
|
220
|
+
passphrase = urlObj.searchParams.get('passphrase') || null;
|
|
221
|
+
try {
|
|
222
|
+
res.setHeader?.('X-Deprecated-Security-Warning', 'Passphrase in query string is deprecated and will be removed; use POST /dsh-key-rotation/export with body');
|
|
223
|
+
} catch (_) {}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
159
226
|
|
|
160
227
|
const settings = ctx.get('settings');
|
|
161
228
|
let currentConfig = {};
|
|
@@ -176,16 +243,21 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
176
243
|
const exportData = masked ? maskConfig(rawConfig) : rawConfig;
|
|
177
244
|
|
|
178
245
|
if (passphrase) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
246
|
+
try {
|
|
247
|
+
const encryptedPayload = await encryptConfig(JSON.stringify(exportData), passphrase);
|
|
248
|
+
json(res, 200, {
|
|
249
|
+
ok: true,
|
|
250
|
+
version: PLUGIN_VERSION,
|
|
251
|
+
exportedAt: new Date().toISOString(),
|
|
252
|
+
masked,
|
|
253
|
+
encrypted: true,
|
|
254
|
+
...encryptedPayload,
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
} catch (err) {
|
|
258
|
+
json(res, 500, { error: { code: 'encryption-failed', message: err.message } });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
189
261
|
}
|
|
190
262
|
|
|
191
263
|
json(res, 200, {
|
|
@@ -227,7 +299,7 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
227
299
|
return;
|
|
228
300
|
}
|
|
229
301
|
try {
|
|
230
|
-
const decryptedStr =
|
|
302
|
+
const decryptedStr = await decryptConfigAsync(body, passphrase);
|
|
231
303
|
incomingConfig = JSON.parse(decryptedStr);
|
|
232
304
|
} catch (err) {
|
|
233
305
|
json(res, 400, { error: { code: 'decryption-failed', message: `Decryption failed: ${err.message}` } });
|
package/lib/ops-keys.js
CHANGED
|
@@ -232,27 +232,8 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
232
232
|
}
|
|
233
233
|
const credentialsService = ctx.get('credentials');
|
|
234
234
|
if (req.method === 'GET') {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (!isValidRef(ref)) {
|
|
238
|
-
json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
try {
|
|
242
|
-
let hit = await (credentialsService?.__dshKeyRotationOriginalResolve ?? credentialsService?.resolve)?.call(credentialsService, ref);
|
|
243
|
-
let raw = hit?.value;
|
|
244
|
-
if (!raw) {
|
|
245
|
-
raw = envValue(ref);
|
|
246
|
-
}
|
|
247
|
-
if (!raw) {
|
|
248
|
-
json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: secret for "${ref}" not found` } });
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
const plain = decryptSecret(raw);
|
|
252
|
-
json(res, 200, { ref, plaintext: plain, tail: keyTail(plain) });
|
|
253
|
-
} catch (err) {
|
|
254
|
-
json(res, 500, { error: { code: 'reveal-failed', message: String(err?.message ?? err) } });
|
|
255
|
-
}
|
|
235
|
+
// Disallow plaintext key reveal without authorized session (#543)
|
|
236
|
+
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: plaintext key extraction via GET is forbidden' } });
|
|
256
237
|
return;
|
|
257
238
|
}
|
|
258
239
|
if (!credentialsService || typeof credentialsService.set !== 'function') {
|
package/lib/ops-test.js
CHANGED
|
@@ -171,7 +171,15 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
171
171
|
const ev = envValue(ref);
|
|
172
172
|
if (ev !== undefined) rawValue = ev;
|
|
173
173
|
}
|
|
174
|
-
|
|
174
|
+
if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
|
|
175
|
+
const dec = decryptSecret(rawValue);
|
|
176
|
+
if (!dec || dec === rawValue || dec.startsWith('enc:v1:')) {
|
|
177
|
+
results.push({ provider, ref, keyTail: '', ok: false, code: 'decryption-failed', message: 'failed to decrypt credential', latencyMs: 0 });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
rawValue = dec;
|
|
181
|
+
}
|
|
182
|
+
let effectiveValue = rawValue;
|
|
175
183
|
const tail = effectiveValue ? keyTail(effectiveValue) : '';
|
|
176
184
|
if (!effectiveValue) {
|
|
177
185
|
results.push({ provider, ref, keyTail: tail, ok: false, code: 'no-credential', latencyMs: 0 });
|
package/lib/pool.js
CHANGED
|
@@ -63,11 +63,13 @@ export function isTrustedBridgeRequest(req) {
|
|
|
63
63
|
if (!(isLoopbackAddress(hostName) || hostName === 'localhost')) return false;
|
|
64
64
|
|
|
65
65
|
const origin = req?.headers?.origin;
|
|
66
|
+
const referer = req?.headers?.referer;
|
|
67
|
+
|
|
66
68
|
if (origin !== undefined) {
|
|
67
69
|
if (!origin) return false; // Explicit empty string or null-like falsy
|
|
68
70
|
try {
|
|
69
71
|
const originUrl = new URL(origin);
|
|
70
|
-
// Official Desktop build shell origin
|
|
72
|
+
// Official Desktop build shell origin (#22, #465, #516)
|
|
71
73
|
if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app' || originUrl.host === 'localhost')) {
|
|
72
74
|
return true;
|
|
73
75
|
}
|
|
@@ -87,12 +89,11 @@ export function isTrustedBridgeRequest(req) {
|
|
|
87
89
|
return true;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
// Mutating requests without Origin: allow
|
|
92
|
+
// Mutating requests without Origin: allow authentic Desktop shell requests (#516, #465) or valid Referer.
|
|
91
93
|
if (isDesktopShellRequest(req)) {
|
|
92
94
|
return true;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
const referer = req?.headers?.referer;
|
|
96
97
|
if (referer) {
|
|
97
98
|
try {
|
|
98
99
|
const refUrl = new URL(referer);
|
package/lib/selection-trace.js
CHANGED
|
@@ -7,7 +7,7 @@ const TRACE_BUFFER_LIMIT = 100;
|
|
|
7
7
|
const traces = [];
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Redacts any sensitive tokens from string / object representation.
|
|
10
|
+
* Redacts any sensitive tokens from string / object representation (#537).
|
|
11
11
|
*/
|
|
12
12
|
export function redactSecret(val) {
|
|
13
13
|
if (typeof val !== 'string') return val;
|
|
@@ -19,6 +19,17 @@ export function redactSecret(val) {
|
|
|
19
19
|
return val;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Aggressively sanitizes arbitrary error/details strings to prevent token/secret leaks in traces (#537).
|
|
24
|
+
*/
|
|
25
|
+
export function sanitizeTraceDetails(str) {
|
|
26
|
+
if (typeof str !== 'string') return '';
|
|
27
|
+
return str
|
|
28
|
+
.replace(/(?:sk-|key-|Bearer\s+|ghp_|token[=:\s]+|api[_-]?key[=:\s]+)[a-zA-Z0-9_\-\.]{8,}/gi, '[REDACTED_SECRET]')
|
|
29
|
+
.replace(/https?:\/\/[^\s/$.?#].[^\s]*[?&](?:token|key|api_key|secret|password)=[^\s&]+/gi, '[REDACTED_URL]')
|
|
30
|
+
.slice(0, 300);
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
export function createSelectionTrace(requestId, options = {}) {
|
|
23
34
|
const reqId = requestId || `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
24
35
|
const startTime = Date.now();
|
|
@@ -43,7 +54,7 @@ export function createSelectionTrace(requestId, options = {}) {
|
|
|
43
54
|
ref: redactSecret(ref),
|
|
44
55
|
decision,
|
|
45
56
|
reasonCode,
|
|
46
|
-
details:
|
|
57
|
+
details: sanitizeTraceDetails(details),
|
|
47
58
|
});
|
|
48
59
|
},
|
|
49
60
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.73",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|