fix: fail closed for diagnostic history reads

Require the decrypted configuration and its complete secret set before loading or returning any diagnostic history data. Propagate configuration failures so the agent response boundary falls back to its generic safe error instead of emitting unredacted history content.

Reject thrown and non-array dedicated history reader failures as unhealthy operations without consulting stale config history. Preserve redaction of reader errors at the agent boundary.

Keep caller-owned history snapshots unchanged by isolating the per-hoster summarizer from the reader array, and cover get_history, list_errors, and server_health with focused red-green regressions.
This commit is contained in:
Sucukdeluxe
2026-08-13 22:48:21 +02:00
parent f03e0c8c22
commit 1c8e9067ee
2 changed files with 110 additions and 33 deletions
+20 -14
View File
@@ -16,18 +16,24 @@ function createCollectors(deps) {
const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps; const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
function _secrets() { function _secrets() {
try { return support.collectSecretValues(loadConfig()); } catch { return []; } return support.collectSecretValues(loadConfig());
} }
function _currentHistory() { function _currentHistory() {
if (typeof loadHistory === 'function') { if (typeof loadHistory === 'function') {
const history = loadHistory(); const history = loadHistory();
return Array.isArray(history) ? history : []; if (!Array.isArray(history)) throw new Error('History reader returned invalid data');
return history;
} }
const cfg = loadConfig(); const cfg = loadConfig();
return Array.isArray(cfg && cfg.history) ? cfg.history : []; return Array.isArray(cfg && cfg.history) ? cfg.history : [];
} }
function _historyContext() {
const secrets = _secrets();
return { history: _currentHistory(), secrets };
}
function _deepRedact(value, secrets) { function _deepRedact(value, secrets) {
return support.valueScrub(value, secrets || _secrets()); return support.valueScrub(value, secrets || _secrets());
} }
@@ -139,10 +145,9 @@ function createCollectors(deps) {
return { events: out.slice(-limit), truncated: out.length > limit }; return { events: out.slice(-limit), truncated: out.length > limit };
} }
function _historyErrors(history, opts) { function _historyErrors(history, opts, secrets) {
const o = opts || {}; const o = opts || {};
const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null; const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null;
const secrets = _secrets();
const errors = []; const errors = [];
const byCategory = {}; const byCategory = {};
for (const batch of (Array.isArray(history) ? history : [])) { for (const batch of (Array.isArray(history) ? history : [])) {
@@ -171,16 +176,17 @@ function createCollectors(deps) {
return { errors, byCategory }; return { errors, byCategory };
} }
function _listErrors(args, history) { function _listErrors(args, history, secrets) {
const a = args || {}; const a = args || {};
const { errors, byCategory } = _historyErrors(history, a); const { errors, byCategory } = _historyErrors(history, a, secrets);
const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000); const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000);
const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history'; const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history';
return { window, total: errors.length, byCategory, errors: errors.slice(-limit) }; return { window, total: errors.length, byCategory, errors: errors.slice(-limit) };
} }
function listErrors(args) { function listErrors(args) {
return _listErrors(args, _currentHistory()); const { history, secrets } = _historyContext();
return _listErrors(args, history, secrets);
} }
function getQueueState(args) { function getQueueState(args) {
@@ -212,12 +218,11 @@ function createCollectors(deps) {
return result; return result;
} }
function _getHistory(args, history) { function _getHistory(args, history, secrets) {
const a = args || {}; const a = args || {};
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200); const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
const perHoster = stats.summarizePerHoster(history); const perHoster = stats.summarizePerHoster([...history]);
const recent = [...history].slice(-limit).reverse(); const recent = [...history].slice(-limit).reverse();
const secrets = _secrets();
const batches = recent.map(b => { const batches = recent.map(b => {
const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 }; const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 };
if (a.includeFiles) { if (a.includeFiles) {
@@ -237,7 +242,8 @@ function createCollectors(deps) {
} }
function getHistory(args) { function getHistory(args) {
return _getHistory(args, _currentHistory()); const { history, secrets } = _historyContext();
return _getHistory(args, history, secrets);
} }
function getRotationState() { function getRotationState() {
@@ -259,10 +265,10 @@ function createCollectors(deps) {
const a = args || {}; const a = args || {};
const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200); const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200);
const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit }; const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit };
const currentHistory = _currentHistory(); const { history: currentHistory, secrets } = _historyContext();
const errors = _listErrors(errArgs, currentHistory); const errors = _listErrors(errArgs, currentHistory, secrets);
const queue = getQueueState({ includeJobs: false }); const queue = getQueueState({ includeJobs: false });
const history = _getHistory({ limit: 5 }, currentHistory); const history = _getHistory({ limit: 5 }, currentHistory, secrets);
const warnings = []; const warnings = [];
if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).'); if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).');
if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`); if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`);
+87 -16
View File
@@ -6,6 +6,7 @@ const path = require('path');
const support = require('../lib/support-bundle'); const support = require('../lib/support-bundle');
const stats = require('../lib/stats'); const stats = require('../lib/stats');
const { createCollectors } = require('../lib/diagnostics-collectors'); const { createCollectors } = require('../lib/diagnostics-collectors');
const { createAgent } = require('../lib/diagnostics-agent');
function makeFixture() { function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
@@ -116,30 +117,100 @@ test('getHistory, listErrors and serverHealth share loadHistory after migration'
assert.equal(health.recentBatches[0].timestamp, health.errors.errors[0].ts, 'serverHealth must summarize one history snapshot'); assert.equal(health.recentBatches[0].timestamp, health.errors.errors[0].ts, 'serverHealth must summarize one history snapshot');
}); });
test('history commands fail closed instead of reading stale config history', () => { test('getHistory, listErrors and serverHealth leave the supplied history snapshot unchanged', () => {
const original = [
{ timestamp: '2026-01-02T00:00:00.000Z', files: [{ name: 'newer.mkv', results: [{ hoster: 'voe.sx', status: 'done' }] }] },
{ timestamp: '2026-01-01T00:00:00.000Z', files: [{ name: 'older.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }] }] }
];
const sortingStats = {
...stats,
summarizePerHoster: history => {
history.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
return stats.summarizePerHoster(history);
}
};
for (const [name, invoke] of [
['listErrors', c => c.listErrors({})],
['getHistory', c => c.getHistory({ includeFiles: true })],
['serverHealth', c => c.serverHealth({})]
]) {
const snapshot = JSON.parse(JSON.stringify(original));
const c = createCollectors({ const c = createCollectors({
loadConfig: () => ({ loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
hosters: {}, loadHistory: () => snapshot,
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats: sortingStats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
});
invoke(c);
assert.deepEqual(snapshot, original, `${name} must not mutate the supplied history snapshot`);
}
});
test('dedicated history reader failures never report healthy empty history or use stale config history', () => {
const sensitive = 'reader-private-value-38152';
const config = {
hosters: { 'voe.sx': [{ apiKey: sensitive }] },
globalSettings: {}, globalSettings: {},
history: [{ timestamp: '2025-12-31T00:00:00.000Z', files: [{ name: 'stale.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'stale error' }] }] }] history: [{ timestamp: '2025-12-31T00:00:00.000Z', files: [{ name: 'stale.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'stale error' }] }] }]
}), };
loadHistory: () => null, for (const [scenario, loadHistory] of [
['throwing reader', () => { throw new Error(`history reader failed with ${sensitive}`); }],
['invalid reader result', () => ({ history: [] })]
]) {
const c = createCollectors({
loadConfig: () => JSON.parse(JSON.stringify(config)),
loadHistory,
getAllLogPaths: () => ({ logDir: os.tmpdir() }), getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats, support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({}) appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
}); });
const health = c.serverHealth({ errorLimit: 5 }); for (const [name, invoke] of [
assert.deepEqual({ ['getHistory', () => c.getHistory({ includeFiles: true })],
historyBatches: c.getHistory({ limit: 5 }).totalBatches, ['listErrors', () => c.listErrors({})],
listedErrors: c.listErrors({ limit: 5 }).total, ['serverHealth', () => c.serverHealth({})]
healthBatches: health.recentBatches.length, ]) {
healthErrors: health.errors.total assert.throws(invoke, /history/i, `${name} must reject a ${scenario}`);
}, { }
historyBatches: 0, const agent = createAgent(c);
listedErrors: 0, for (const op of ['get_history', 'list_errors', 'server_health']) {
healthBatches: 0, const response = agent.handle(op, { includeFiles: true });
healthErrors: 0 const json = JSON.stringify(response);
assert.equal(response.ok, false, `${op} must report the ${scenario} as unhealthy`);
assert.match(response.error, /history/i);
assert.ok(!json.includes(sensitive));
assert.ok(!json.includes('stale.mkv'));
}
}
});
test('history operations fail closed when configured secrets cannot be loaded', () => {
const sensitive = 'history-private-value-27491';
let historyReads = 0;
const c = createCollectors({
loadConfig: () => { throw new Error(`secret decryption failed near ${sensitive}`); },
loadHistory: () => {
historyReads++;
return [{ timestamp: '2026-01-04T00:00:00.000Z', files: [{ name: `${sensitive}.mkv`, results: [{ hoster: 'voe.sx', status: 'error', error: `opaque ${sensitive}` }] }] }];
},
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
support, stats,
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
}); });
for (const [name, invoke] of [
['getHistory', () => c.getHistory({ includeFiles: true })],
['listErrors', () => c.listErrors({})],
['serverHealth', () => c.serverHealth({})]
]) {
assert.throws(invoke, /secret decryption failed/, `${name} must fail without the configured redaction secrets`);
}
assert.equal(historyReads, 0, 'history must not be read before the redaction secrets are available');
const agent = createAgent(c);
for (const op of ['get_history', 'list_errors', 'server_health']) {
const response = agent.handle(op, { includeFiles: true });
assert.deepEqual(response, { ok: false, error: 'diagnostic response could not be safely returned' });
assert.ok(!JSON.stringify(response).includes(sensitive));
}
}); });
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => { test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {