Compare commits
2
Commits
98737995a9
...
089f5d2902
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
089f5d2902 | ||
|
|
0e7bb97129 |
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* DC-081: log-insights dispose path + keepDays input validation hardening.
|
||||
*
|
||||
* Two coupled bugs surfaced in the 2026-08-19 sweep:
|
||||
*
|
||||
* 1. log-insights.js hardcoded the audit-log.json + security-events.jsonl
|
||||
* paths to `/opt/dashcaddy/dashcaddy-api/data/...`, which does NOT
|
||||
* exist inside the production container — files live at
|
||||
* `/app/data/...` (mounted via the existing data bind). The dispose
|
||||
* endpoint silently no-op'd: `fs.readFile('/opt/.../audit-log.json')`
|
||||
* hit the `.catch` arm → `auditData = []` → wrote an empty file back.
|
||||
*
|
||||
* 2. `parseInt(req.body.keepDays) || 30` accepted negative numbers. A
|
||||
* keepDays of -1000 produces a cutoff +3 years in the future and
|
||||
* deletes 100% of the audit log. Operators should not be able to wipe
|
||||
* forensic context by clicking through with a typo.
|
||||
*
|
||||
* DC-081 fix:
|
||||
* - `_resolvePaths()` returns `{ auditPath, secPath }` from
|
||||
* `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`
|
||||
* — same canonical resolution as the audit-logger module.
|
||||
* - `_validateKeepDays(raw)` rejects out-of-range / wrong-type input
|
||||
* with an Error BEFORE any file IO.
|
||||
* - POST /log-insights/dispose now requires `{ keepDays: integer 1..3650, confirm: true }`.
|
||||
* The pre-confirm preview is read-only.
|
||||
*
|
||||
* Verified live on DNS2 2026-08-19: `/app/data/audit-log.json` (318 KB)
|
||||
* and `/app/data/security-events.jsonl` (15 MB) both exist; the old
|
||||
* `/opt/dashcaddy/dashcaddy-api/data/...` paths are ENOENT in the container.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const logInsightsMod = require('../../routes/log-insights');
|
||||
|
||||
function tmpAuditLogger() {
|
||||
// The route module only uses auditLogger.log() inside the dispose
|
||||
// confirm branch — we wire a minimal stub for the dispose tests.
|
||||
return {
|
||||
query: async () => [],
|
||||
log: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function tmpSecurityEventStore() {
|
||||
return {
|
||||
query: () => ({ events: [], total: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRouter(opts = {}) {
|
||||
const mod = logInsightsMod;
|
||||
return mod({
|
||||
asyncHandler: (fn) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
ok: (res, data) => res.json({ success: true, ...data }),
|
||||
auditLogger: opts.auditLogger || tmpAuditLogger(),
|
||||
securityEventStore: opts.securityEventStore || tmpSecurityEventStore(),
|
||||
});
|
||||
}
|
||||
|
||||
function makeApp(router) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(router);
|
||||
// Capture errors so a thrown ValidationError doesn't crash the test
|
||||
// runner — the route uses asyncHandler which forwards to next().
|
||||
app.use((err, req, res, next) => res.status(err.statusCode || 500).json({ success: false, error: err.message, code: err.code }));
|
||||
return app;
|
||||
}
|
||||
|
||||
// Drive requests through http directly so we exercise the FULL Express
|
||||
// middleware stack (body parser, error handler).
|
||||
function start(app) {
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function stop(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
function httpJson(server, httpMethod, urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = server.address().port;
|
||||
const data = httpMethod === 'GET' ? '' : JSON.stringify({});
|
||||
const req = require('http').request({
|
||||
hostname: '127.0.0.1', port, path: urlPath, method: httpMethod,
|
||||
headers: httpMethod === 'GET'
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
||||
catch (_) { resolve({ status: res.statusCode, body }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (httpMethod !== 'GET') req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('routes/log-insights [DC-081]', () => {
|
||||
describe('_validateKeepDays', () => {
|
||||
const { _validateKeepDays } = logInsightsMod.__test;
|
||||
|
||||
test('rejects undefined / null / missing', () => {
|
||||
expect(() => _validateKeepDays(undefined)).toThrow(/required/i);
|
||||
expect(() => _validateKeepDays(null)).toThrow(/required/i);
|
||||
expect(() => _validateKeepDays()).toThrow(/required/i);
|
||||
});
|
||||
|
||||
test('rejects non-finite numbers (NaN, Infinity, -Infinity)', () => {
|
||||
expect(() => _validateKeepDays(NaN)).toThrow(/finite/i);
|
||||
expect(() => _validateKeepDays(Infinity)).toThrow(/finite/i);
|
||||
expect(() => _validateKeepDays(-Infinity)).toThrow(/finite/i);
|
||||
expect(() => _validateKeepDays('not-a-number')).toThrow(/finite/i);
|
||||
});
|
||||
|
||||
test('rejects non-integers (floats, strings of floats)', () => {
|
||||
expect(() => _validateKeepDays(1.5)).toThrow(/integer/i);
|
||||
expect(() => _validateKeepDays(30.7)).toThrow(/integer/i);
|
||||
expect(() => _validateKeepDays('30.5')).toThrow(/integer/i);
|
||||
});
|
||||
|
||||
test('rejects out-of-range values — the DC-081 core fix', () => {
|
||||
// The pre-fix bug: parseInt(-1000, 10) === -1000, accepted as keepDays.
|
||||
// cutoff = Date.now() - (-1000 * 86400000) = +3 years in the future,
|
||||
// then "delete all entries older than +3 years" = delete everything.
|
||||
expect(() => _validateKeepDays(-1)).toThrow(/between 1 and 3650/i);
|
||||
expect(() => _validateKeepDays(-1000)).toThrow(/between 1 and 3650/i);
|
||||
expect(() => _validateKeepDays(0)).toThrow(/between 1 and 3650/i);
|
||||
expect(() => _validateKeepDays(3651)).toThrow(/between 1 and 3650/i);
|
||||
expect(() => _validateKeepDays(1000000)).toThrow(/between 1 and 3650/i);
|
||||
});
|
||||
|
||||
test('accepts integers in [1, 3650]', () => {
|
||||
expect(_validateKeepDays(1)).toBe(1);
|
||||
expect(_validateKeepDays(30)).toBe(30);
|
||||
expect(_validateKeepDays(90)).toBe(90);
|
||||
expect(_validateKeepDays(365)).toBe(365);
|
||||
expect(_validateKeepDays(3650)).toBe(3650);
|
||||
});
|
||||
|
||||
test('coerces numeric strings', () => {
|
||||
expect(_validateKeepDays('30')).toBe(30);
|
||||
expect(_validateKeepDays('3650')).toBe(3650);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_resolvePaths', () => {
|
||||
const { _resolvePaths } = logInsightsMod.__test;
|
||||
|
||||
test('falls back to platformPaths.dataDir when env unset', () => {
|
||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||
delete process.env.AUDIT_LOG_FILE;
|
||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||
try {
|
||||
const { auditPath, secPath } = _resolvePaths();
|
||||
// platformPaths.dataDir is /app/data in the container, /etc/dashcaddy on host
|
||||
expect(auditPath.endsWith('audit-log.json')).toBe(true);
|
||||
expect(secPath.endsWith('security-events.jsonl')).toBe(true);
|
||||
// Audit + security should land in the same data dir
|
||||
expect(path.dirname(auditPath)).toBe(path.dirname(secPath));
|
||||
} finally {
|
||||
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
||||
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||
}
|
||||
});
|
||||
|
||||
test('honours AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE env overrides', () => {
|
||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||
process.env.AUDIT_LOG_FILE = '/tmp/dc-081-audit.json';
|
||||
process.env.SECURITY_EVENT_LOG_FILE = '/tmp/dc-081-sec.jsonl';
|
||||
try {
|
||||
const { auditPath, secPath, auditPathFrom, secPathFrom } = _resolvePaths();
|
||||
expect(auditPath).toBe('/tmp/dc-081-audit.json');
|
||||
expect(secPath).toBe('/tmp/dc-081-sec.jsonl');
|
||||
expect(auditPathFrom).toBe('env');
|
||||
expect(secPathFrom).toBe('env');
|
||||
} finally {
|
||||
if (prevAudit === undefined) delete process.env.AUDIT_LOG_FILE;
|
||||
else process.env.AUDIT_LOG_FILE = prevAudit;
|
||||
if (prevSec === undefined) delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||
else process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||
}
|
||||
});
|
||||
|
||||
test('matches the canonical paths used by audit-logger + event-store', async () => {
|
||||
// Sanity: load both modules' resolved paths and assert they match
|
||||
// what _resolvePaths returns. This catches a future refactor that
|
||||
// moves one but not the others (the bug class that produced DC-081).
|
||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||
delete process.env.AUDIT_LOG_FILE;
|
||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||
try {
|
||||
const auditLoggerMod = require('../../src/security/audit-logger');
|
||||
const eventStoreMod = require('../../src/security/event-store');
|
||||
// Trigger event-store module-load (it captures ENV at require time)
|
||||
eventStoreMod.getStore();
|
||||
const { auditPath, secPath } = _resolvePaths();
|
||||
// The audit-logger module exports a singleton; its private
|
||||
// AUDIT_LOG_FILE is not directly readable. Instead, we verify the
|
||||
// shape: both paths share the same dataDir and use the canonical
|
||||
// filenames.
|
||||
expect(path.basename(auditPath)).toBe('audit-log.json');
|
||||
expect(path.basename(secPath)).toBe('security-events.jsonl');
|
||||
// And the dirname matches platformPaths.dataDir
|
||||
const platformPaths = require('../../platform-paths');
|
||||
expect(path.dirname(auditPath)).toBe(platformPaths.dataDir);
|
||||
expect(path.dirname(secPath)).toBe(platformPaths.dataDir);
|
||||
// Also sanity that the singleton logger at least exists
|
||||
expect(auditLoggerMod).toBeDefined();
|
||||
} finally {
|
||||
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
||||
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /log-insights/dispose (TOTP-gated in production; here we hit the handler directly)', () => {
|
||||
let server;
|
||||
let app;
|
||||
let tmpDir;
|
||||
let auditFile;
|
||||
let secFile;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dc-081-'));
|
||||
auditFile = path.join(tmpDir, 'audit-log.json');
|
||||
secFile = path.join(tmpDir, 'security-events.jsonl');
|
||||
// Stage files so the route resolves them via env override.
|
||||
process.env.AUDIT_LOG_FILE = auditFile;
|
||||
process.env.SECURITY_EVENT_LOG_FILE = secFile;
|
||||
const router = buildRouter();
|
||||
app = makeApp(router);
|
||||
server = await start(app);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stop(server);
|
||||
delete process.env.AUDIT_LOG_FILE;
|
||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function postKeepDays(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = server.address().port;
|
||||
const data = JSON.stringify(body);
|
||||
const req = require('http').request({
|
||||
hostname: '127.0.0.1', port, path: '/log-insights/dispose',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
||||
catch (_) { resolve({ status: res.statusCode, body }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects negative keepDays with 400 + DC-081_INVALID_KEEP_DAYS', async () => {
|
||||
const r = await postKeepDays({ keepDays: -1000 });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
expect(r.body.error).toMatch(/between 1 and 3650/i);
|
||||
});
|
||||
|
||||
test('rejects 0 keepDays (no-op-but-lies)', async () => {
|
||||
const r = await postKeepDays({ keepDays: 0 });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('rejects keepDays=Infinity (NaN-via-parseInt fallback)', async () => {
|
||||
const r = await postKeepDays({ keepDays: Infinity });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('rejects non-integer keepDays', async () => {
|
||||
const r = await postKeepDays({ keepDays: 30.5 });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('rejects missing keepDays', async () => {
|
||||
const r = await postKeepDays({});
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('rejects keepDays > 3650 (10-year cap)', async () => {
|
||||
const r = await postKeepDays({ keepDays: 10000 });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('preview pass: returns wouldDelete count without writing', async () => {
|
||||
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString(); // 100 days ago
|
||||
const newTs = new Date(Date.now() - 5 * 86400000).toISOString(); // 5 days ago
|
||||
await fsp.writeFile(auditFile, JSON.stringify([
|
||||
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
||||
{ id: 'a2', timestamp: oldTs, action: 'service.delete' },
|
||||
{ id: 'a3', timestamp: newTs, action: 'auth.totp-verify' },
|
||||
]));
|
||||
await fsp.writeFile(secFile, [
|
||||
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
||||
JSON.stringify({ id: 's2', timestamp: oldTs, severity: 'info' }),
|
||||
JSON.stringify({ id: 's3', timestamp: newTs, severity: 'info' }),
|
||||
].join('\n') + '\n');
|
||||
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.preview).toBe(true);
|
||||
expect(r.body.wouldDelete.auditEntries).toBe(2);
|
||||
expect(r.body.wouldDelete.securityEvents).toBe(2);
|
||||
// Files untouched
|
||||
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||
expect(afterAudit.length).toBe(3);
|
||||
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean);
|
||||
expect(afterSec.length).toBe(3);
|
||||
});
|
||||
|
||||
test('confirm pass: actually deletes old entries, keeps new ones', async () => {
|
||||
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString();
|
||||
const newTs = new Date(Date.now() - 5 * 86400000).toISOString();
|
||||
await fsp.writeFile(auditFile, JSON.stringify([
|
||||
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
||||
{ id: 'a2', timestamp: newTs, action: 'auth.totp-verify' },
|
||||
]));
|
||||
await fsp.writeFile(secFile, [
|
||||
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
||||
JSON.stringify({ id: 's2', timestamp: newTs, severity: 'info' }),
|
||||
].join('\n') + '\n');
|
||||
|
||||
const r = await postKeepDays({ keepDays: 30, confirm: true });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.disposed).toBe(true);
|
||||
expect(r.body.deleted.auditEntries).toBe(1);
|
||||
expect(r.body.deleted.securityEvents).toBe(1);
|
||||
expect(r.body.remaining.auditEntries).toBe(1);
|
||||
expect(r.body.remaining.securityEvents).toBe(1);
|
||||
|
||||
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||
expect(afterAudit.map(e => e.id)).toEqual(['a2']);
|
||||
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean).map(JSON.parse);
|
||||
expect(afterSec.map(e => e.id)).toEqual(['s2']);
|
||||
});
|
||||
|
||||
test('confirm=false treated as preview (not confirm)', async () => {
|
||||
const r = await postKeepDays({ keepDays: 30, confirm: false });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.preview).toBe(true);
|
||||
// confirm was false, so no dispose
|
||||
expect(r.body.disposed).toBeUndefined();
|
||||
});
|
||||
|
||||
test('preview response includes resolved paths so operator knows what files will be touched', async () => {
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.paths.auditPath).toBe(auditFile);
|
||||
expect(r.body.paths.secPath).toBe(secFile);
|
||||
});
|
||||
|
||||
test('handles missing audit-log file gracefully on preview', async () => {
|
||||
await fsp.unlink(auditFile).catch(() => {});
|
||||
// fs.readFile().catch returns '[]', so preview reports 0 deletions
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.wouldDelete.auditEntries).toBe(0);
|
||||
});
|
||||
|
||||
test('returns 500 DC-081_AUDIT_PARSE_FAILED on corrupt audit-log file', async () => {
|
||||
await fsp.writeFile(auditFile, 'this-is-not-json{');
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body.code).toBe('DC-081_AUDIT_PARSE_FAILED');
|
||||
});
|
||||
|
||||
test('returns 500 DC-081_AUDIT_SHAPE_INVALID if audit-log is a JSON object, not array', async () => {
|
||||
await fsp.writeFile(auditFile, JSON.stringify({ not: 'an array' }));
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body.code).toBe('DC-081_AUDIT_SHAPE_INVALID');
|
||||
});
|
||||
|
||||
test('DC-081 CORE: pre-fix keptDays=-1000 no longer wipes everything', async () => {
|
||||
// Sanity-test the actual fix: a negative keepDays would, pre-fix,
|
||||
// compute a cutoff in the FUTURE and then delete everything. After
|
||||
// DC-081 it's a 400 with a clear error before any file read.
|
||||
const r = await postKeepDays({ keepDays: -1000, confirm: true });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.success).toBe(false);
|
||||
// No file IO occurred — confirm that an unrelated existing audit
|
||||
// log file would survive. Since we already wiped tmpDir's auditFile
|
||||
// is empty, write a sentinel and confirm it's still there after.
|
||||
await fsp.writeFile(auditFile, JSON.stringify([{ id: 'sentinel', timestamp: new Date().toISOString() }]));
|
||||
const r2 = await postKeepDays({ keepDays: -1000, confirm: true });
|
||||
expect(r2.status).toBe(400);
|
||||
const after = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||
expect(after.length).toBe(1);
|
||||
expect(after[0].id).toBe('sentinel');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* DC-082: Update-manager image-name parsing for docker-compose prefixed names.
|
||||
*
|
||||
* The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to
|
||||
* `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub.
|
||||
* Compose-prefixed names (single hyphen, no slash, lowercase) need to
|
||||
* split on the FIRST hyphen to recover `<project>/<service>` — that's
|
||||
* the actual upstream namespace for a compose-prefixed image.
|
||||
*
|
||||
* The fix also adds a "no upstream registry image, skip cleanly" path
|
||||
* for when the authed GET 401s against a compose-prefixed name (the
|
||||
* compose-prefixed image is built locally and not published to Docker
|
||||
* Hub). That should log as info, not error.
|
||||
*/
|
||||
const updateManager = require('../src/managers/update-manager');
|
||||
|
||||
describe('DC-082 update-manager / compose-prefixed image names', () => {
|
||||
let um = updateManager; // module exports the singleton instance
|
||||
|
||||
describe('_composeProjectToRepo', () => {
|
||||
test('splits dashcaddy-dashcaddy-api on the first hyphen', () => {
|
||||
expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api');
|
||||
});
|
||||
|
||||
test('splits myproject-myservice on the first hyphen', () => {
|
||||
expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice');
|
||||
});
|
||||
|
||||
test('splits multi-hyphen names on the FIRST hyphen only', () => {
|
||||
// "myproj-grandchild-service" -> "myproj/grandchild-service"
|
||||
// (first hyphen is the project/service boundary; later hyphens are
|
||||
// part of the service name like docker-compose's `web-cache`).
|
||||
expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service');
|
||||
});
|
||||
|
||||
test('returns null for slash-namespaced names (handled by other path)', () => {
|
||||
expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null);
|
||||
expect(um._composeProjectToRepo('library/nginx')).toBe(null);
|
||||
expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null);
|
||||
});
|
||||
|
||||
test('returns null for Docker Official Image names (no hyphen)', () => {
|
||||
expect(um._composeProjectToRepo('nginx')).toBe(null);
|
||||
expect(um._composeProjectToRepo('alpine')).toBe(null);
|
||||
expect(um._composeProjectToRepo('node')).toBe(null);
|
||||
});
|
||||
|
||||
test('returns null for empty / malformed input', () => {
|
||||
expect(um._composeProjectToRepo('')).toBe(null);
|
||||
expect(um._composeProjectToRepo(null)).toBe(null);
|
||||
expect(um._composeProjectToRepo(undefined)).toBe(null);
|
||||
expect(um._composeProjectToRepo(123)).toBe(null);
|
||||
expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen
|
||||
expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen
|
||||
// The regex tolerates mixed-case via the /i flag for defensiveness
|
||||
// even though Docker Compose names are typically lowercase — the
|
||||
// important shape constraints are the letter/digit/underscore/hyphen
|
||||
// charset and the non-empty two-part split.
|
||||
});
|
||||
|
||||
test('accepts names with underscores and digits (compose allows)', () => {
|
||||
expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service');
|
||||
expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2');
|
||||
});
|
||||
|
||||
test('rejects names with chars compose never produces', () => {
|
||||
// dot/colon/slash should never pass — they're either already-namespaced
|
||||
// or invalid in a Docker Compose service name.
|
||||
expect(um._composeProjectToRepo('foo:bar')).toBe(null);
|
||||
expect(um._composeProjectToRepo('foo.bar')).toBe(null);
|
||||
expect(um._composeProjectToRepo('foo/bar')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_isNotPublishedError', () => {
|
||||
test('returns true for HTTP 401 + compose-prefixed remainder', () => {
|
||||
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for HTTP 401 with non-compose-prefixed remainder', () => {
|
||||
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||
expect(um._isNotPublishedError(err, 'nginx')).toBe(false);
|
||||
expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false);
|
||||
expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for non-401 errors', () => {
|
||||
const err = new Error('network timeout after 10s');
|
||||
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for malformed error or remainder', () => {
|
||||
expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||
expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||
expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||
expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false);
|
||||
expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestImageDigest (mocked fetchWithReliability)', () => {
|
||||
let originalFetch;
|
||||
let originalFetchAuth;
|
||||
let originalFetchRetry;
|
||||
beforeEach(() => {
|
||||
originalFetch = um.fetchWithReliability.bind(um);
|
||||
originalFetchAuth = um.fetchAuthToken.bind(um);
|
||||
});
|
||||
|
||||
test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => {
|
||||
const calls = [];
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
calls.push(opts);
|
||||
// Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token
|
||||
// (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub).
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
headers: {
|
||||
'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"',
|
||||
},
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' };
|
||||
};
|
||||
um.fetchAuthToken = async () => 'fake-token';
|
||||
const { log } = require('../src/utils/logging');
|
||||
const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {});
|
||||
const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest');
|
||||
expect(result).toBe(null);
|
||||
|
||||
// First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest
|
||||
// NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest
|
||||
const firstPath = calls[0].path;
|
||||
expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest');
|
||||
expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api');
|
||||
|
||||
// The 401 after auth should produce an INFO log about "no upstream"
|
||||
// NOT an error log.
|
||||
const infoMsgs = infoSpy.mock.calls.map((c) => c[1]);
|
||||
expect(infoMsgs).toContain('No upstream registry image — skipping update check');
|
||||
const errorMsgs = errorSpy.mock.calls.map((c) => c[1]);
|
||||
expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth');
|
||||
|
||||
infoSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('official image (nginx) still probes library/nginx', async () => {
|
||||
const calls = [];
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
calls.push(opts);
|
||||
if (calls.length === 1) {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' };
|
||||
}
|
||||
return { statusCode: 200, headers: {}, body: '' };
|
||||
};
|
||||
|
||||
const result = await um.getLatestImageDigest('nginx:latest');
|
||||
expect(result).toBe('sha256:abc123');
|
||||
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||
});
|
||||
|
||||
test('library/nginx (explicit) probes library/nginx', async () => {
|
||||
const calls = [];
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
calls.push(opts);
|
||||
if (calls.length === 1) {
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' };
|
||||
}
|
||||
return { statusCode: 200, headers: {}, body: '' };
|
||||
};
|
||||
|
||||
const result = await um.getLatestImageDigest('library/nginx:latest');
|
||||
expect(result).toBe('sha256:abc');
|
||||
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||
});
|
||||
|
||||
test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => {
|
||||
const calls = [];
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
calls.push(opts);
|
||||
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' };
|
||||
};
|
||||
|
||||
const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest');
|
||||
expect(result).toBe('sha256:ghcr');
|
||||
expect(calls[0].hostname).toBe('ghcr.io');
|
||||
expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest');
|
||||
});
|
||||
|
||||
test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => {
|
||||
let callCount = 0;
|
||||
um.fetchWithReliability = async (opts) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' },
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' };
|
||||
};
|
||||
um.fetchAuthToken = async () => 'fake-token';
|
||||
|
||||
const result = await um.getLatestImageDigest('myproj-myservice:latest');
|
||||
expect(result).toBe(null);
|
||||
// Probe targets the correct namespace (myproj/myservice), not library/.
|
||||
const firstCall = await (async () => {
|
||||
let p;
|
||||
um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; };
|
||||
await um.getLatestImageDigest('myproj-myservice:latest');
|
||||
return p;
|
||||
})();
|
||||
expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
um.fetchWithReliability = originalFetch;
|
||||
um.fetchAuthToken = originalFetchAuth;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,103 @@
|
||||
/**
|
||||
* DC-081: Plain-English log insights + dispose endpoint
|
||||
*
|
||||
* GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||
* POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||
*
|
||||
* DC-081 hardening (paired with the deploy path fix):
|
||||
* - AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE were HARDCODED to
|
||||
* `/opt/dashcaddy/dashcaddy-api/data/...` which DOES NOT EXIST in the
|
||||
* production container — files live at `/app/data/...`. The dispose
|
||||
* endpoint silently no-op'd (read empty arrays, wrote empty arrays
|
||||
* back) and the GET endpoint dropped the storage-size block. Both
|
||||
* paths now use the same canonical resolution as the audit-logger
|
||||
* itself: `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`.
|
||||
* - keepDays was unbounded — `parseInt(req.body.keepDays) || 30` accepted
|
||||
* negative numbers (e.g. -1000 → cutoff = +3 years in the future,
|
||||
* deleting 100% of forensic context) and non-integers (Infinity,
|
||||
* floats). Now validated to an integer in [1, 3650] (1 day .. 10 years)
|
||||
* before any file read.
|
||||
* - confirm gate added: must send { confirm: true, keepDays: N } — the
|
||||
* preview pass is read-only, the confirm pass writes. Matches the
|
||||
* audit-logs/DELETE confirm=CLEAR pattern.
|
||||
* - The dispose handler now uses a single shared `_resolvePaths()` helper
|
||||
* to keep GET and POST in lockstep (and so a future path-config change
|
||||
* touches one site, not four).
|
||||
*
|
||||
* Pre-DC-081 verification: from inside the running container, both
|
||||
* `/app/data/audit-log.json` (318 KB) and `/app/data/security-events.jsonl`
|
||||
* (15 MB) exist, but the old hardcoded `/opt/dashcaddy/dashcaddy-api/data/...`
|
||||
* paths resolve to ENOENT. The dispose endpoint therefore did nothing;
|
||||
* this fix wires it back to the actual files.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
/**
|
||||
* Resolve the canonical paths for the audit log + security event log.
|
||||
*
|
||||
* Both store the file path in their own module-level constants, so any
|
||||
* environment override (e.g. AUDIT_LOG_FILE=...) is honoured here too —
|
||||
* exactly the same behaviour as src/security/audit-logger.js and
|
||||
* src/security/event-store.js. Without this, a container with
|
||||
* AUDIT_LOG_FILE set would see the dispose handler read from one file
|
||||
* and the audit-logger write to a different one.
|
||||
*
|
||||
* @returns {{auditPath: string, secPath: string, auditPathFrom: string, secPathFrom: string}}
|
||||
* paths + the source ("env" or "default") so tests can verify.
|
||||
*/
|
||||
function _resolvePaths() {
|
||||
const auditPath = process.env.AUDIT_LOG_FILE
|
||||
|| path.join(platformPaths.dataDir, 'audit-log.json');
|
||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE
|
||||
|| path.join(platformPaths.dataDir, 'security-events.jsonl');
|
||||
return {
|
||||
auditPath,
|
||||
secPath,
|
||||
auditPathFrom: process.env.AUDIT_LOG_FILE ? 'env' : 'default',
|
||||
secPathFrom: process.env.SECURITY_EVENT_LOG_FILE ? 'env' : 'default',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the keepDays input. Coerces + bounds-checks BEFORE any file
|
||||
* read so a malicious or mistyped client can't:
|
||||
* - pass a negative number (cutoff = far future → wipe 100%)
|
||||
* - pass Infinity (parseInt(Infinity, 10) === NaN, currently falls
|
||||
* through `|| 30` — fixed to fail-fast instead)
|
||||
* - pass a non-integer (e.g. 1.5 → cutoff mid-day, off-by-half-day)
|
||||
* - pass 0 (no-op-but-lies) or 10000 (way past retention policy)
|
||||
*
|
||||
* @param {unknown} raw - value from req.body.keepDays
|
||||
* @returns {number} validated integer in [1, 3650]
|
||||
* @throws {Error} when out of range / wrong type
|
||||
*/
|
||||
function _validateKeepDays(raw) {
|
||||
if (raw === undefined || raw === null) {
|
||||
throw new Error('keepDays is required (integer in [1, 3650])');
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new Error(`keepDays must be a finite number (received ${JSON.stringify(raw)})`);
|
||||
}
|
||||
if (!Number.isInteger(n)) {
|
||||
throw new Error(`keepDays must be an integer (received ${raw})`);
|
||||
}
|
||||
if (n < 1 || n > 3650) {
|
||||
throw new Error(`keepDays must be between 1 and 3650 (received ${n})`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
||||
const router = express.Router();
|
||||
// Resolve once at module init so GET + POST both use the same files.
|
||||
// If the env vars change at runtime (rare — start.sh wires them at
|
||||
// container start), operators re-deploy rather than mutate env mid-flight.
|
||||
const { auditPath, secPath } = _resolvePaths();
|
||||
|
||||
// GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||
router.get('/log-insights', asyncHandler(async (req, res) => {
|
||||
@@ -74,16 +169,18 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
||||
}
|
||||
|
||||
// --- Storage info ---
|
||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
||||
// DC-081: read from the canonical resolved paths (NOT the hardcoded
|
||||
// /opt/... paths that don't exist in the container). Empty-object
|
||||
// fallback on ENOENT — the file may legitimately be absent on a
|
||||
// fresh install where the audit-logger hasn't written yet.
|
||||
let storage = {};
|
||||
try {
|
||||
const a = await fs.stat(auditPath);
|
||||
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
|
||||
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length, path: auditPath };
|
||||
} catch {}
|
||||
try {
|
||||
const s = await fs.stat(secPath);
|
||||
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
|
||||
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length, path: secPath };
|
||||
} catch {}
|
||||
|
||||
ok(res, {
|
||||
@@ -108,16 +205,44 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
||||
}));
|
||||
|
||||
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||
//
|
||||
// Two-call pattern:
|
||||
// 1. { keepDays: 30 } → preview, no writes
|
||||
// 2. { keepDays: 30, confirm: true } → actually delete
|
||||
//
|
||||
// DC-081 hardening:
|
||||
// - keepDays is validated to integer [1, 3650] BEFORE any file read.
|
||||
// A negative keepDays (e.g. -1000) would previously compute a
|
||||
// cutoff +3 years in the future, then delete every entry older
|
||||
// than that — i.e. 100% of the audit log. Now rejected at the gate.
|
||||
// - auditPath / secPath come from the canonical _resolvePaths() helper
|
||||
// so the container's actual /app/data files are read (the pre-fix
|
||||
// hardcoded /opt/dashcaddy/dashcaddy-api/data/... paths resolved to
|
||||
// ENOENT inside the container, so the endpoint silently did nothing).
|
||||
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
||||
const keepDays = parseInt(req.body.keepDays) || 30;
|
||||
// Validate keepDays first — fail-fast before any file IO so a bad
|
||||
// client never touches disk.
|
||||
let keepDays;
|
||||
try {
|
||||
keepDays = _validateKeepDays(req.body?.keepDays);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ success: false, error: e.message, code: 'DC-081_INVALID_KEEP_DAYS' });
|
||||
}
|
||||
const confirm = req.body.confirm === true;
|
||||
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
||||
|
||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
||||
|
||||
// Read both files via the canonical resolved paths (NOT the hardcoded
|
||||
// /opt/... paths from before — those don't exist in the container).
|
||||
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
||||
const auditData = JSON.parse(auditRaw);
|
||||
let auditData;
|
||||
try {
|
||||
auditData = JSON.parse(auditRaw);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ success: false, error: `audit-log file is corrupt (${auditPath}): ${e.message}`, code: 'DC-081_AUDIT_PARSE_FAILED' });
|
||||
}
|
||||
if (!Array.isArray(auditData)) {
|
||||
return res.status(500).json({ success: false, error: `audit-log file is not an array (${auditPath})`, code: 'DC-081_AUDIT_SHAPE_INVALID' });
|
||||
}
|
||||
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
|
||||
|
||||
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
|
||||
@@ -127,16 +252,40 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
||||
if (!confirm) {
|
||||
ok(res, {
|
||||
preview: true,
|
||||
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.',
|
||||
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true, keepDays: ' + keepDays + '} to proceed.',
|
||||
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||
cutoffDate: cutoff
|
||||
cutoffDate: cutoff,
|
||||
paths: { auditPath, secPath },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute cleanup
|
||||
// Execute cleanup. Audit the wipe FIRST via the audit-logger so the
|
||||
// fact that a delete happened is itself preserved (matches the
|
||||
// audit-logs/DELETE + error-logs/DELETE pattern).
|
||||
try {
|
||||
if (auditLogger && typeof auditLogger.log === 'function') {
|
||||
await auditLogger.log({
|
||||
action: 'log-insights.dispose',
|
||||
resource: 'audit-log,security-events',
|
||||
outcome: 'success',
|
||||
details: {
|
||||
keepDays,
|
||||
cutoff,
|
||||
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { /* don't fail the dispose on audit-side errors */ }
|
||||
|
||||
// Rewrite audit-log.json atomically — write to tmp + rename so a
|
||||
// crash mid-write can't leave the file half-empty (the file is read
|
||||
// by state-manager on every container start; a corrupt file would
|
||||
// block the whole API).
|
||||
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
||||
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
|
||||
const tmpAudit = auditPath + '.tmp';
|
||||
await fs.writeFile(tmpAudit, JSON.stringify(keptAudit, null, 2));
|
||||
await fs.rename(tmpAudit, auditPath);
|
||||
|
||||
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
|
||||
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
|
||||
@@ -145,9 +294,16 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
||||
disposed: true,
|
||||
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
|
||||
cutoffDate: cutoff
|
||||
cutoffDate: cutoff,
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
// DC-081: export helpers for direct unit testing (the route handlers are
|
||||
// otherwise unreachable from outside the factory closure).
|
||||
module.exports.__test = {
|
||||
_resolvePaths,
|
||||
_validateKeepDays,
|
||||
};
|
||||
@@ -168,12 +168,39 @@ class UpdateManager extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Get latest image digest from registry
|
||||
*
|
||||
* DC-082: when the image name is a docker-compose prefixed name like
|
||||
* `dashcaddy-dashcaddy-api:latest` (single hyphen-separated, no slash),
|
||||
* the existing code normalized it to `library/dashcaddy-dashcaddy-api`
|
||||
* before probing Docker Hub. The actual upstream namespace for a
|
||||
* compose-prefixed image is `<project>/<service>` (with slash) — Docker
|
||||
* Compose hyphenates the project name and service name when tagging
|
||||
* locally. The pre-fix code probed the wrong repo, Docker Hub returned
|
||||
* HTTP 401 (the repo doesn't exist), and the error log showed
|
||||
* `Docker Hub registry returned HTTP 401 after auth` on every restart
|
||||
* for the local dashcaddy-api image. The fix: split on the FIRST hyphen
|
||||
* for compose-prefixed names so the lookup targets the correct
|
||||
* namespace.
|
||||
*
|
||||
* Compose-prefixed shape: `^[a-z0-9]+-[a-z0-9][a-z0-9_-]*$` (no slash,
|
||||
* lowercase, both halves non-empty). Examples:
|
||||
* dashcaddy-dashcaddy-api -> dashcaddy/dashcaddy-api
|
||||
* myproject-myservice -> myproject/myservice
|
||||
* nginx -> library/nginx (official, unchanged)
|
||||
* library/nginx -> library/nginx (official, unchanged)
|
||||
* dashcaddy/some-image -> dashcaddy/some-image (already has slash)
|
||||
* ghcr.io/x/y -> ghcr.io/x/y (handled below)
|
||||
*/
|
||||
async getLatestImageDigest(imageName) {
|
||||
// DC-082: declare `remainder` at the function scope so the catch block
|
||||
// can classify the error against the image-name shape (compose-prefixed
|
||||
// local images produce a steady-state 401 that should log as info, not
|
||||
// error).
|
||||
let remainder = imageName;
|
||||
try {
|
||||
// Parse image name — strip any leading registry host first
|
||||
let imageTag = 'latest';
|
||||
let remainder = imageName;
|
||||
remainder = imageName;
|
||||
const lastColon = imageName.lastIndexOf(':');
|
||||
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
|
||||
const lastSlash = imageName.lastIndexOf('/');
|
||||
@@ -187,8 +214,19 @@ class UpdateManager extends EventEmitter {
|
||||
return await this.getGhcrDigest(remainder, imageTag);
|
||||
}
|
||||
|
||||
// Docker Hub images (library/nginx OR org/image with single slash)
|
||||
if (!remainder.includes('/') || remainder.split('/').length === 2) {
|
||||
// Docker Hub images (library/nginx OR org/image with single slash).
|
||||
// Special-case docker-compose prefixed names (single hyphen, no slash,
|
||||
// lowercase) — split on the FIRST hyphen to recover the original
|
||||
// `<project>/<service>` namespace. See DC-082.
|
||||
if (!remainder.includes('/')) {
|
||||
const composeRepo = this._composeProjectToRepo(remainder);
|
||||
if (composeRepo) {
|
||||
return await this.getDockerHubDigest(composeRepo, imageTag);
|
||||
}
|
||||
// Not a compose-prefixed name — fall through to the library/ default
|
||||
return await this.getDockerHubDigest(remainder, imageTag);
|
||||
}
|
||||
if (remainder.split('/').length === 2) {
|
||||
return await this.getDockerHubDigest(remainder, imageTag);
|
||||
}
|
||||
|
||||
@@ -196,11 +234,72 @@ class UpdateManager extends EventEmitter {
|
||||
log.warn('update', 'Custom registry not yet supported', { remainder });
|
||||
return null;
|
||||
} catch (error) {
|
||||
// DC-082: a "registry returned HTTP 401 after auth" against a
|
||||
// compose-prefixed local image is the steady-state when the image
|
||||
// is built locally and the upstream namespace on Docker Hub
|
||||
// doesn't exist (or is private). The token endpoint returns 200
|
||||
// with an empty-access JWT, and the authed manifest GET 401s.
|
||||
// Log these as a clean info not-found line instead of an error
|
||||
// so dashboards and PagerDuty don't fire on every restart.
|
||||
if (this._isNotPublishedError(error, remainder)) {
|
||||
log.info('update', 'No upstream registry image — skipping update check', { imageName, remainder });
|
||||
return null;
|
||||
}
|
||||
log.error('update', error, null, { imageName });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-082: split a docker-compose prefixed image name on the FIRST hyphen
|
||||
* to recover the original `<project>/<service>` namespace. Returns null
|
||||
* for names that don't match the compose-prefixed shape — callers fall
|
||||
* through to the standard library/-prefixed official-image path.
|
||||
*
|
||||
* Compose-prefixed shape:
|
||||
* - Contains exactly one or more hyphens
|
||||
* - No slash
|
||||
* - Lowercase letters / digits / hyphens / underscores only
|
||||
* - Both halves (before first hyphen, after first hyphen) are non-empty
|
||||
* - First char is a letter or digit (not a hyphen)
|
||||
*/
|
||||
_composeProjectToRepo(remainder) {
|
||||
if (typeof remainder !== 'string' || remainder.length === 0) return null;
|
||||
if (remainder.includes('/')) return null; // already namespaced
|
||||
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
|
||||
// Not a compose-prefixed name — let the library/ path handle it
|
||||
// (this is the official-image path: e.g. `nginx`, `alpine`).
|
||||
return null;
|
||||
}
|
||||
const firstHyphen = remainder.indexOf('-');
|
||||
// Defensive: indexOf must find a hyphen (regex requires it), but guard
|
||||
// against any future regex drift.
|
||||
if (firstHyphen <= 0 || firstHyphen === remainder.length - 1) return null;
|
||||
const project = remainder.substring(0, firstHyphen);
|
||||
const service = remainder.substring(firstHyphen + 1);
|
||||
if (!project || !service) return null;
|
||||
return `${project}/${service}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-082: detect the "registry returned 401 after auth" pattern that
|
||||
* signals "this image has no public upstream on Docker Hub" (as opposed
|
||||
* to a genuine auth failure or transient network error). Steady-state
|
||||
* for compose-prefixed local images that aren't published.
|
||||
*/
|
||||
_isNotPublishedError(error, remainder) {
|
||||
if (!error || typeof error.message !== 'string') return false;
|
||||
if (!error.message.includes('HTTP 401')) return false;
|
||||
// Constrain to the compose-prefixed path — a real auth failure on a
|
||||
// legitimate `library/foo` or `namespace/foo` probe should still log
|
||||
// as an error (it never auto-heals).
|
||||
if (typeof remainder !== 'string' || remainder.includes('/')) return false;
|
||||
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image digest from GitHub Container Registry (ghcr.io)
|
||||
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
|
||||
|
||||
Reference in New Issue
Block a user