Harden diagnostic response redaction

Redact every diagnostic response at the agent boundary, fail closed when sanitization cannot complete, and remove Windows, UNC, and slash-UNC paths from returned data. Preserve benign text while removing complete configured secret values, including nested JSON escapes and quoted HTML credential fields. Add focused regression coverage for collector errors, successful responses, support bundles, path variants, and punctuation secrets.
This commit is contained in:
Sucukdeluxe
2026-08-13 21:08:26 +02:00
parent 76ad81a0d3
commit 4c48044a95
6 changed files with 244 additions and 39 deletions
+19 -4
View File
@@ -1,3 +1,5 @@
const { valueScrub } = require('./support-bundle');
function createAgent(collectors) {
const OPS = {
get_system_info: (a) => collectors.getSystemInfo(a),
@@ -14,15 +16,28 @@ function createAgent(collectors) {
get_health: () => collectors.getHealth()
};
function redactResponse(value) {
try {
const redacted = typeof collectors.redactResponse === 'function'
? collectors.redactResponse(value)
: valueScrub(value, []);
const response = valueScrub(redacted, []);
if (!response || typeof response !== 'object' || Array.isArray(response)) throw new Error('invalid redaction result');
return response;
} catch {
return { ok: false, error: 'diagnostic response could not be safely returned' };
}
}
function handle(op, args) {
const fn = (typeof op === 'string' && Object.prototype.hasOwnProperty.call(OPS, op)) ? OPS[op] : null;
if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` };
if (typeof fn !== 'function') return redactResponse({ ok: false, error: `unknown or non-readonly op: ${op}` });
try {
const data = fn(args || {});
if (data && data.ok === false) return data;
return { ok: true, data };
if (data && data.ok === false) return redactResponse(data);
return redactResponse({ ok: true, data });
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
return redactResponse({ ok: false, error: String((e && e.message) || e) });
}
}
+6 -13
View File
@@ -20,18 +20,11 @@ function createCollectors(deps) {
}
function _deepRedact(value, secrets) {
const s = secrets || _secrets();
const walk = (v) => {
if (typeof v === 'string') return support.redactLogText(v, s);
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === 'object') {
const o = {};
for (const k of Object.keys(v)) o[k] = walk(v[k]);
return o;
}
return v;
};
try { return walk(value); } catch { return value; }
return support.valueScrub(value, secrets || _secrets());
}
function redactResponse(value) {
return _deepRedact(value);
}
function _resolveLogPath(name, backup) {
@@ -274,7 +267,7 @@ function createCollectors(deps) {
return {
getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents,
listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth,
READABLE_LOGS
redactResponse, READABLE_LOGS
};
}
+83 -19
View File
@@ -35,24 +35,28 @@ function collectSecretValues(config) {
function redactConfiguredSecrets(text, secrets) {
if (!Array.isArray(secrets)) return text;
const values = Array.from(new Set(secrets.filter(value => typeof value === 'string' && value.length > 0)))
const values = Array.from(new Set(secrets
.filter(value => typeof value === 'string' && value.length > 0)
.flatMap(value => {
const variants = [value];
for (let index = 0; index < 3; index++) {
const escaped = JSON.stringify(variants[variants.length - 1]).slice(1, -1);
if (escaped === variants[variants.length - 1]) break;
variants.push(escaped);
}
return variants;
})))
.sort((a, b) => b.length - a.length);
let out = text;
for (const value of values) {
if (value.length >= 6) {
out = out.split(value).join(REDACTED);
continue;
}
let offset = 0;
while (offset < out.length) {
const index = out.indexOf(value, offset);
if (index < 0) break;
const first = value[0];
const last = value[value.length - 1];
const before = index > 0 ? out[index - 1] : '';
const after = index + value.length < out.length ? out[index + value.length] : '';
const identifier = character => /[A-Za-z0-9_]/.test(character);
if ((!identifier(first) || !identifier(before)) && (!identifier(last) || !identifier(after))) {
const continuation = character => /[A-Za-z0-9_.]/.test(character);
if (!continuation(before) && !continuation(after)) {
out = `${out.slice(0, index)}${REDACTED}${out.slice(index + value.length)}`;
offset = index + REDACTED.length;
} else {
@@ -64,23 +68,83 @@ function redactConfiguredSecrets(text, secrets) {
}
function redactHtmlCredentialFields(text) {
return text.replace(/<input\b[^>]*>/gi, input => {
let out = '';
let offset = 0;
const lower = text.toLowerCase();
while (offset < text.length) {
const start = lower.indexOf('<input', offset);
if (start < 0) {
out += text.slice(offset);
break;
}
out += text.slice(offset, start);
let quote = '';
let end = start + 6;
for (; end < text.length; end++) {
const character = text[end];
if (quote) {
if (character === quote) quote = '';
} else if (character === '"' || character === "'") {
quote = character;
} else if (character === '>') {
end++;
break;
}
}
const input = text.slice(start, end);
const sensitive = /\btype\s*=\s*["']?password\b/i.test(input)
|| /\b(?:name|id)\s*=\s*["']?(?:password|passwd|api[_-]?(?:key|token)|token|secret|authorization|cookie|session(?:[_-]?id)?)\b/i.test(input);
if (!sensitive) return input;
return input
.replace(/(\bvalue\s*=\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`)
.replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`);
});
out += sensitive
? input
.replace(/(\bvalue\s*=\s*)(["'])([\s\S]*?)\2/gi, `$1$2${REDACTED}$2`)
.replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`)
: input;
offset = end;
}
return out;
}
function redactAbsolutePaths(text) {
const isDriveStart = (value, index) => /[A-Za-z]/.test(value[index] || '')
&& !/[A-Za-z0-9]/.test(value[index - 1] || '')
&& value[index + 1] === ':'
&& /[\\/]/.test(value[index + 2] || '');
const isBackslashUncStart = (value, index) => {
if (value[index] !== '\\' || value[index + 1] !== '\\' || value[index - 1] === '\\') return false;
let cursor = index + 2;
while (value[cursor] === '\\') cursor++;
if (value[cursor] === '?') return true;
const separator = value.indexOf('\\', cursor);
return separator > cursor;
};
const isSlashUncStart = (value, index) => value[index] === '/'
&& value[index + 1] === '/'
&& !/[:/]/.test(value[index - 1] || '')
&& !/[\/]/.test(value[index + 2] || '')
&& value.indexOf('/', index + 2) > index + 2;
let out = '';
let index = 0;
while (index < text.length) {
if (!isDriveStart(text, index) && !isBackslashUncStart(text, index) && !isSlashUncStart(text, index)) {
out += text[index];
index++;
continue;
}
let end = index;
while (end < text.length && !/[\r\n"'<>|]/.test(text[end])) end++;
const candidate = text.slice(index, end).replace(/\s+(?:trigger|error|outcome|hoster|attempt|status|code)=.*$/i, '');
out += '<redacted-path>';
index += candidate.length;
}
return out;
}
function redactLogText(text, secrets) {
if (typeof text !== 'string' || !text) return text;
let out = redactConfiguredSecrets(text, secrets);
out = redactHtmlCredentialFields(out)
.replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2')
.replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '<redacted-path>')
.replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '<redacted-path>')
.replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2');
out = redactAbsolutePaths(out)
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
.replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
.replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
@@ -99,7 +163,7 @@ function valueScrub(value, secrets) {
if (Array.isArray(value)) return value.map(entry => valueScrub(entry, secrets));
if (typeof value === 'object') {
const out = {};
for (const [key, entry] of Object.entries(value)) out[key] = valueScrub(entry, secrets);
for (const [key, entry] of Object.entries(value)) out[redactLogText(key, secrets)] = valueScrub(entry, secrets);
return out;
}
return value;