From 557a2ba9ab0c2cda8d19be47cd5ad975f7a28a4b Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 18:19:53 -0400 Subject: [PATCH] feat(audit): make audit records tamper-evident and queryable Add hash-chained integrity metadata, redacted attribution fields, duplicate outcome suppression, and authorized filters so transfer investigations can detect silent tampering and correlate privileged mutations without exposing secrets. --- CHANGELOG.md | 10 + src/controllers/auditController.js | 46 +++- src/controllers/transferController.js | 8 +- src/controllers/userController.js | 2 +- src/routes/auditRoutes.js | 10 +- src/services/auditService.js | 304 +++++++++++++++++---- src/services/transferService.js | 33 ++- src/services/userService.js | 4 +- src/utils/auditCrypto.js | 139 ++++++++++ test/auditIntegrity.test.js | 375 ++++++++++++++++++++++++++ 10 files changed, 870 insertions(+), 61 deletions(-) create mode 100644 src/utils/auditCrypto.js create mode 100644 test/auditIntegrity.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e4e0a..144ab5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ When preparing a new release: ### Added +- Tamper-evident audit log: hash-chained `prevHash` / `entryHash` metadata, + `GET /api/audit/integrity` for authorized operators, and attribution fields + (`actor`, `scope`, `target`, `correlationId`, `outcome`, redacted `changes`). + Duplicate outcome events for the same privileged mutation (same action, + target, correlation id, and outcome) are suppressed. `GET /api/audit` + accepts `action`, `scope`, `outcome`, `correlationId`, and `actor` filters. + Secrets in changes are redacted at write time. Archive/unarchive now emit + outcome events. Optional `AUDIT_ACTOR_SECRET` (falls back to + `PAGINATION_CURSOR_SECRET`) fingerprints actors without storing raw tokens. + - Cursor pagination for `GET /api/transfers` and `GET /api/audit`. Pass `?cursor=` (with optional `?order=asc|desc`) to page by an indexed position instead of a row offset; responses carry a `pageInfo` block with diff --git a/src/controllers/auditController.js b/src/controllers/auditController.js index 799b428..45b9fae 100644 --- a/src/controllers/auditController.js +++ b/src/controllers/auditController.js @@ -7,33 +7,69 @@ const { buildHistoryPage } = require('../utils/historyPage'); * Audit log controllers. */ +/** + * Collect optional attribution / outcome filters from the query string. + * Empty strings are treated as absent so `?action=` does not over-filter. + * @param {import('express').Request} req + * @returns {{ action: string|null, scope: string|null, outcome: string|null, + * correlationId: string|null, actor: string|null }} + */ +function readFilters(req) { + const pick = (name) => { + const raw = req.query[name]; + if (raw == null || raw === '') return null; + return String(raw); + }; + + return { + action: pick('action'), + scope: pick('scope'), + outcome: pick('outcome'), + correlationId: pick('correlationId'), + actor: pick('actor'), + }; +} + /** * GET /api/audit * Return audit log entries, newest first by default. * - * Supports ?resourceId= to filter by resource, ?order=asc|desc, ?limit=, and - * either ?cursor= (stable under concurrent writes) or the legacy ?offset=. + * Supports ?resourceId=, ?action=, ?scope=, ?outcome=, ?correlationId=, + * ?actor=, ?order=asc|desc, ?limit=, and either ?cursor= or legacy ?offset=. + * Responses only ever contain redacted changes — secrets are stripped at write. */ function listAuditEntries(req, res) { const resourceId = req.query.resourceId == null || req.query.resourceId === '' ? null : String(req.query.resourceId); - const filters = { resourceId }; + const attribution = readFilters(req); + const filters = { resourceId, ...attribution }; const { items, envelope } = buildHistoryPage({ req, collection: 'audit', filters, defaultOrder: 'desc', - query: (args) => auditService.queryEntries({ resourceId, ...args }), - countTotal: () => auditService.countEntries(resourceId), + query: (args) => auditService.queryEntries({ resourceId, ...attribution, ...args }), + countTotal: () => auditService.countEntries(resourceId, attribution), resolvePosition: (seq) => auditService.positionKeyAt(seq, resourceId), }); res.json({ ...envelope, entries: items }); } +/** + * GET /api/audit/integrity + * Report whether the hash chain is intact. Authorized operators use this to + * detect silent tampering without reading every entry's payload. + */ +function getIntegrity(req, res) { + const report = auditService.verifyIntegrity(); + res.json(report); +} + module.exports = { + getIntegrity, listAuditEntries, }; diff --git a/src/controllers/transferController.js b/src/controllers/transferController.js index 4fb2e72..313afbe 100644 --- a/src/controllers/transferController.js +++ b/src/controllers/transferController.js @@ -135,7 +135,7 @@ function getTransfer(req, res) { * Mark a transfer as claimed by the recipient. */ function claimTransfer(req, res) { - const transfer = transferService.claimTransfer(req.params.id, req.id); + const transfer = transferService.claimTransfer(req.params.id, req.id, req.token); res.json(transfer); } @@ -144,7 +144,7 @@ function claimTransfer(req, res) { * Cancel a pending transfer. */ function cancelTransfer(req, res) { - const transfer = transferService.cancelTransfer(req.params.id, req.id); + const transfer = transferService.cancelTransfer(req.params.id, req.id, req.token); res.json(transfer); } @@ -153,7 +153,7 @@ function cancelTransfer(req, res) { * Archive a transfer, hiding it from default list results. */ function archiveTransfer(req, res) { - const transfer = transferService.archiveTransfer(req.params.id); + const transfer = transferService.archiveTransfer(req.params.id, req.id, req.token); res.json(transfer); } @@ -162,7 +162,7 @@ function archiveTransfer(req, res) { * Unarchive a transfer, restoring it to default list results. */ function unarchiveTransfer(req, res) { - const transfer = transferService.unarchiveTransfer(req.params.id); + const transfer = transferService.unarchiveTransfer(req.params.id, req.id, req.token); res.json(transfer); } diff --git a/src/controllers/userController.js b/src/controllers/userController.js index fa0153a..fcd096f 100644 --- a/src/controllers/userController.js +++ b/src/controllers/userController.js @@ -32,7 +32,7 @@ function getUser(req, res) { * Create a new user. */ function createUser(req, res) { - const user = userService.createUser(req.body, req.id); + const user = userService.createUser(req.body, req.id, req.token); res.status(201).json(user); } diff --git a/src/routes/auditRoutes.js b/src/routes/auditRoutes.js index 30fb2a1..12cf445 100644 --- a/src/routes/auditRoutes.js +++ b/src/routes/auditRoutes.js @@ -7,8 +7,16 @@ const auditController = require('../controllers/auditController'); const router = express.Router(); +// GET /api/audit/integrity +// Hash-chain verification for authorized operators (requires audit:read). +router.get( + '/integrity', + requireScope(['audit:read']), + asyncHandler(auditController.getIntegrity) +); + // GET /api/audit -// Lists audit log entries (newest first). Supports ?resourceId= and ?limit=/?offset=. +// Lists audit log entries (newest first). Supports resource and attribution filters. router.get('/', requireScope(['audit:read']), asyncHandler(auditController.listAuditEntries)); module.exports = router; diff --git a/src/services/auditService.js b/src/services/auditService.js index 4e51403..f7b6507 100644 --- a/src/services/auditService.js +++ b/src/services/auditService.js @@ -3,68 +3,228 @@ const { newId } = require('../utils/ids'); const { OrderedIndex } = require('../utils/orderedIndex'); const config = require('../config'); +const { + GENESIS_HASH, + actorRef, + computeEntryHash, + redact, + scopeFromAction, +} = require('../utils/auditCrypto'); /** * Audit log service. * - * Records an immutable, append-only log of write operations performed - * against the API. Entries are stored in memory (consistent with the - * rest of the in-memory store) and are therefore cleared when the - * process restarts. + * Records an append-only, hash-chained log of privileged mutations. Each entry + * binds to its predecessor via `prevHash` / `entryHash`, so a silent edit, + * deletion, or reorder is detectable by walking the chain. Sensitive fields in + * `changes` are redacted before storage so authorized operators can filter + * without ever being shown secrets. * - * Supported actions (non-exhaustive, extend as needed): + * Supported actions (non-exhaustive): * transfer.created transfer.claimed transfer.cancelled + * transfer.archived transfer.unarchived * user.created * * Each entry captures: - * id — unique audit entry id (aud_ prefix) - * action — dot-namespaced action string (e.g. "transfer.created") - * resourceId — id of the created / mutated resource - * payload — arbitrary context snapshot (sanitised by the caller) - * requestId — correlation id from the originating HTTP request (optional) - * at — ISO-8601 timestamp of when the entry was recorded + * id, action, scope, target, actor, correlationId, outcome, changes, + * resourceId / requestId / payload — compat aliases + * chainSeq, prevHash, entryHash — integrity metadata + * at — ISO-8601 timestamp */ -/** - * Entries live in an append-only ordered index rather than a plain array. - * - * The index adds two things a plain array cannot: a dense sequence number per - * entry, which gives cursor pagination a deterministic tie-breaker when several - * entries share a millisecond, and a secondary index by `resourceId`, so - * filtering by resource no longer scans the whole log. - */ const auditIndex = new OrderedIndex({ sortKeyOf: (entry) => entry.at, groupKeyOf: (entry) => entry.resourceId, }); +/** Tip of the integrity chain (hash of the most recently appended entry). */ +let tipHash = GENESIS_HASH; + +/** + * Event identity used to suppress duplicate outcome events for the same + * privileged mutation (e.g. a retried request carrying the same correlation id). + * @param {object} parts + * @returns {string|null} null when the event cannot be safely deduplicated. + */ +function eventIdentity({ action, target, correlationId, outcome }) { + if (!correlationId) return null; + return `${action}\u0000${target}\u0000${correlationId}\u0000${outcome}`; +} + +/** @type {Map} */ +const eventsByIdentity = new Map(); + /** - * Append a new entry to the audit log. + * Append a new entry to the audit log, or return the existing one when the + * same privileged mutation is recorded again under the same correlation id. * * @param {object} params - * @param {string} params.action - action identifier (e.g. "transfer.created") - * @param {string} params.resourceId - id of the affected resource - * @param {object} [params.payload] - additional context to record - * @param {string} [params.requestId]- request correlation id - * @returns {object} the newly created audit entry + * @param {string} params.action + * @param {string} [params.resourceId] - legacy alias for target + * @param {string} [params.target] + * @param {object} [params.payload] - legacy alias for changes (redacted) + * @param {object} [params.changes] + * @param {string} [params.requestId] - legacy alias for correlationId + * @param {string} [params.correlationId] + * @param {string} [params.actor] - raw token or already-fingerprinted ref + * @param {string} [params.scope] + * @param {'success'|'failure'|string} [params.outcome] + * @returns {object} the newly created (or previously recorded) audit entry */ -function addEntry({ action, resourceId, payload = {}, requestId } = {}) { +function addEntry({ + action, + resourceId, + target, + payload, + changes, + requestId, + correlationId, + actor, + scope, + outcome = 'success', +} = {}) { if (!action) throw new Error('audit.addEntry: action is required'); - if (!resourceId) throw new Error('audit.addEntry: resourceId is required'); + + const resolvedTarget = target != null && target !== '' + ? String(target) + : (resourceId != null && resourceId !== '' ? String(resourceId) : null); + if (!resolvedTarget) throw new Error('audit.addEntry: resourceId is required'); + + const resolvedCorrelation = correlationId != null && correlationId !== '' + ? String(correlationId) + : (requestId != null && requestId !== '' ? String(requestId) : null); + + const resolvedOutcome = outcome || 'success'; + const identity = eventIdentity({ + action, + target: resolvedTarget, + correlationId: resolvedCorrelation, + outcome: resolvedOutcome, + }); + + if (identity) { + const existing = eventsByIdentity.get(identity); + if (existing) return existing; + } + + // Accept either a raw token (fingerprinted here) or a precomputed `actor:…` ref. + let resolvedActor; + if (actor == null || actor === '') { + resolvedActor = 'system'; + } else if (String(actor).startsWith('actor:') || actor === 'system') { + resolvedActor = String(actor); + } else { + resolvedActor = actorRef(actor); + } + + const resolvedScope = scope || scopeFromAction(action); + const redactedChanges = redact( + changes != null ? changes : (payload != null ? payload : {}) + ); + + const chainSeq = auditIndex.size; + const prevHash = tipHash; + const at = new Date().toISOString(); const entry = { id: newId(), action, - resourceId, - payload, - requestId: requestId || null, - at: new Date().toISOString(), + scope: resolvedScope, + target: resolvedTarget, + // Compat aliases kept so existing consumers and tests keep working. + resourceId: resolvedTarget, + actor: resolvedActor, + correlationId: resolvedCorrelation, + requestId: resolvedCorrelation, + outcome: resolvedOutcome, + changes: redactedChanges, + payload: redactedChanges, + chainSeq, + prevHash, + at, }; + entry.entryHash = computeEntryHash(entry, prevHash); auditIndex.append(entry); + tipHash = entry.entryHash; + + if (identity) { + eventsByIdentity.set(identity, entry); + } + return entry; } +/** + * Walk the chain and recompute every hash. + * + * Detects in-place field edits, broken predecessor links, and gaps. Used by + * operators and by regression tests for the original failure mode (silent + * mutation of an audit record). + * + * @returns {{ valid: boolean, checked: number, tipHash: string, + * brokenAt: number|null, reason: string|null }} + */ +function verifyIntegrity() { + let expectedPrev = GENESIS_HASH; + const records = auditIndex.records; + + for (let i = 0; i < records.length; i += 1) { + const entry = records[i].item; + + if (entry.chainSeq !== i) { + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: `chainSeq mismatch at index ${i}: expected ${i}, got ${entry.chainSeq}`, + }; + } + + if (entry.prevHash !== expectedPrev) { + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: `prevHash mismatch at chainSeq ${entry.chainSeq}`, + }; + } + + const recomputed = computeEntryHash(entry, expectedPrev); + if (recomputed !== entry.entryHash) { + return { + valid: false, + checked: i, + tipHash, + brokenAt: i, + reason: `entryHash mismatch at chainSeq ${entry.chainSeq}`, + }; + } + + expectedPrev = entry.entryHash; + } + + if (expectedPrev !== tipHash) { + return { + valid: false, + checked: records.length, + tipHash, + brokenAt: records.length, + reason: 'tipHash does not match the final entry hash', + }; + } + + return { + valid: true, + checked: records.length, + tipHash, + brokenAt: null, + reason: null, + }; +} + /** * Return all audit entries, newest first. * @returns {Array} @@ -79,31 +239,65 @@ function getEntries() { * @returns {Array} */ function getEntriesForResource(resourceId) { - // A nullish id matches no resource. Guarded explicitly because the index - // treats a null group key as "the whole index". if (resourceId == null || resourceId === '') return []; return auditIndex.recordsFor(String(resourceId)).map((record) => record.item).reverse(); } +/** + * Residual filter over integrity / attribution fields that are not covered by + * the secondary index. Secrets never participate — only redacted changes and + * actor fingerprints are visible to callers. + * @param {object} filters + * @returns {(entry: object) => boolean} + */ +function buildMatch(filters = {}) { + const { + action, + scope, + outcome, + correlationId, + actor, + } = filters; + + const hasResidual = action != null || scope != null || outcome != null + || correlationId != null || actor != null; + if (!hasResidual) return null; + + return (entry) => { + if (action != null && entry.action !== String(action)) return false; + if (scope != null && entry.scope !== String(scope)) return false; + if (outcome != null && entry.outcome !== String(outcome)) return false; + if (correlationId != null && entry.correlationId !== String(correlationId)) return false; + if (actor != null && entry.actor !== String(actor)) return false; + return true; + }; +} + /** * Page through the audit log using the ordered index. * - * Entries are immutable once written, so the sort position of an entry never - * changes. That is what makes a cursor into this log stable: a page boundary - * recorded now still means the same thing after any number of later appends. - * * @param {object} [options] - * @param {string} [options.resourceId] - restrict to one resource via the secondary index. - * @param {'asc'|'desc'} [options.order] - defaults to newest first. + * @param {string} [options.resourceId] + * @param {string} [options.action] + * @param {string} [options.scope] + * @param {string} [options.outcome] + * @param {string} [options.correlationId] + * @param {string} [options.actor] + * @param {'asc'|'desc'} [options.order] * @param {number} [options.limit] - * @param {number|null} [options.afterSeq] - exclusive start position from a cursor. - * @param {number} [options.skip] - legacy offset support. - * @param {number} [options.maxScan] - per-request work budget. + * @param {number|null} [options.afterSeq] + * @param {number} [options.skip] + * @param {number} [options.maxScan] * @returns {{ items: object[], last: object|null, hasMore: boolean, scanned: number, * scanTruncated: boolean, skipped: number }} */ function queryEntries({ resourceId, + action, + scope, + outcome, + correlationId, + actor, order = 'desc', limit = config.pagination.defaultLimit, afterSeq = null, @@ -117,6 +311,7 @@ function queryEntries({ afterSeq, skip, maxScan, + match: buildMatch({ action, scope, outcome, correlationId, actor }), }); } @@ -135,12 +330,27 @@ function positionKeyAt(seq, resourceId) { /** * Number of entries recorded for a resource, or in the whole log. + * When residual filters are supplied the count walks matching entries so + * page envelopes stay accurate for authorized filtered queries. * @param {string} [resourceId] + * @param {object} [filters] * @returns {number} */ -function countEntries(resourceId) { - if (resourceId == null || resourceId === '') return auditIndex.size; - return auditIndex.recordsFor(String(resourceId)).length; +function countEntries(resourceId, filters = {}) { + const match = buildMatch(filters); + if (!match) { + if (resourceId == null || resourceId === '') return auditIndex.size; + return auditIndex.recordsFor(String(resourceId)).length; + } + + const records = resourceId == null || resourceId === '' + ? auditIndex.records + : auditIndex.recordsFor(String(resourceId)); + let count = 0; + for (const record of records) { + if (match(record.item)) count += 1; + } + return count; } /** @@ -148,14 +358,18 @@ function countEntries(resourceId) { */ function reset() { auditIndex.reset(); + tipHash = GENESIS_HASH; + eventsByIdentity.clear(); } module.exports = { addEntry, + actorRef, countEntries, getEntries, getEntriesForResource, positionKeyAt, queryEntries, reset, + verifyIntegrity, }; diff --git a/src/services/transferService.js b/src/services/transferService.js index 1517f62..0decba4 100644 --- a/src/services/transferService.js +++ b/src/services/transferService.js @@ -281,6 +281,8 @@ function createTransferUnchecked(data, requestId, idempotency) { sendAmount: transfer.sendAmount, }, requestId, + actor: idempotency ? idempotency.actor : undefined, + outcome: 'success', }); if (idempotency) { @@ -319,7 +321,7 @@ function transition(transfer, nextStatus) { * @param {string} [requestId] - optional correlation id for audit logging * @returns {object} */ -function claimTransfer(id, requestId) { +function claimTransfer(id, requestId, actor) { const transfer = getTransferOrThrow(id); transition(transfer, TRANSFER_STATUS.CLAIMED); transfer.claimableBalanceId = stellarService.createClaimableBalanceId(); @@ -329,6 +331,8 @@ function claimTransfer(id, requestId) { resourceId: transfer.id, payload: { claimableBalanceId: transfer.claimableBalanceId }, requestId, + actor, + outcome: 'success', }); return transfer; @@ -340,7 +344,7 @@ function claimTransfer(id, requestId) { * @param {string} [requestId] - optional correlation id for audit logging * @returns {object} */ -function cancelTransfer(id, requestId) { +function cancelTransfer(id, requestId, actor) { const transfer = getTransferOrThrow(id); transition(transfer, TRANSFER_STATUS.CANCELLED); @@ -349,6 +353,8 @@ function cancelTransfer(id, requestId) { resourceId: transfer.id, payload: {}, requestId, + actor, + outcome: 'success', }); return transfer; @@ -361,12 +367,21 @@ function cancelTransfer(id, requestId) { * @param {string} id * @returns {object} */ -function archiveTransfer(id) { +function archiveTransfer(id, requestId, actor) { const transfer = getTransferOrThrow(id); if (!transfer.archivedAt) { const timestamp = nextTimestamp(transfer.updatedAt); transfer.archivedAt = timestamp; transfer.updatedAt = timestamp; + + auditService.addEntry({ + action: 'transfer.archived', + resourceId: transfer.id, + payload: { archivedAt: transfer.archivedAt }, + requestId, + actor, + outcome: 'success', + }); } return transfer; } @@ -376,13 +391,23 @@ function archiveTransfer(id) { * @param {string} id * @returns {object} */ -function unarchiveTransfer(id) { +function unarchiveTransfer(id, requestId, actor) { const transfer = getTransferOrThrow(id); if (!transfer.archivedAt) { throw ApiError.conflict(`Transfer is not archived: ${id}`); } transfer.archivedAt = null; transfer.updatedAt = nextTimestamp(transfer.updatedAt); + + auditService.addEntry({ + action: 'transfer.unarchived', + resourceId: transfer.id, + payload: {}, + requestId, + actor, + outcome: 'success', + }); + return transfer; } diff --git a/src/services/userService.js b/src/services/userService.js index 1c2895d..87f8772 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -45,7 +45,7 @@ function getUserOrThrow(id) { * @param {string} [requestId] - optional correlation id for audit logging * @returns {object} */ -function createUser(data, requestId) { +function createUser(data, requestId, actor) { const user = { id: prefixedId('usr'), name: data.name, @@ -60,6 +60,8 @@ function createUser(data, requestId) { resourceId: user.id, payload: { name: user.name, country: user.country }, requestId, + actor, + outcome: 'success', }); return user; diff --git a/src/utils/auditCrypto.js b/src/utils/auditCrypto.js new file mode 100644 index 0000000..900457e --- /dev/null +++ b/src/utils/auditCrypto.js @@ -0,0 +1,139 @@ +'use strict'; + +const crypto = require('crypto'); + +/** + * Cryptographic helpers for tamper-evident audit records. + * + * The hash chain binds each entry to its predecessor so a silent edit, insert, + * or reorder is detectable by recomputing the chain. Actor references are + * keyed digests of the API token so operators can filter by who acted without + * ever storing or returning the raw secret. + */ + +/** Genesis predecessor for the first entry in a chain. */ +const GENESIS_HASH = '0'.repeat(64); + +/** + * Secret used to fingerprint actors. Falls back to the pagination cursor + * secret so a single ops configuration covers both, then to a per-process + * random value (correct for the in-memory demo store). + */ +const ACTOR_SECRET = process.env.AUDIT_ACTOR_SECRET + || process.env.PAGINATION_CURSOR_SECRET + || crypto.randomBytes(32).toString('hex'); + +/** + * Field names (case-insensitive, ignoring `_` / `-`) that must never appear + * in stored or returned audit changes. Matched on every nested object key. + */ +const SENSITIVE_KEY_PATTERN = /^(password|passwd|secret|token|api[_-]?key|authorization|auth|bearer|private[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|ssn|cvv|pin|credential|credentials)$/i; + +const REDACTED = '[REDACTED]'; + +/** + * Deterministic JSON encoding so hash inputs do not depend on key order. + * @param {*} value + * @returns {string} + */ +function canonicalize(value) { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'null'; + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(',')}}`; +} + +/** + * SHA-256 hex digest of a canonicalized value. + * @param {*} value + * @returns {string} + */ +function sha256(value) { + return crypto.createHash('sha256').update(canonicalize(value), 'utf8').digest('hex'); +} + +/** + * Stable, non-reversible actor reference derived from an API token. + * Anonymous / missing actors collapse to the literal `"system"`. + * @param {string|null|undefined} token + * @returns {string} + */ +function actorRef(token) { + if (token == null || token === '') return 'system'; + return `actor:${crypto.createHmac('sha256', ACTOR_SECRET).update(String(token)).digest('hex').slice(0, 16)}`; +} + +/** + * Deep-clone `value`, replacing sensitive keys with `[REDACTED]`. + * Non-objects are returned as-is. Arrays are walked element-wise. + * @param {*} value + * @returns {*} + */ +function redact(value) { + if (value == null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(redact); + + const out = {}; + for (const [key, child] of Object.entries(value)) { + if (SENSITIVE_KEY_PATTERN.test(key.replace(/-/g, '_'))) { + out[key] = REDACTED; + } else if (child != null && typeof child === 'object') { + out[key] = redact(child); + } else { + out[key] = child; + } + } + return out; +} + +/** + * Compute the integrity hash for one audit entry given its predecessor hash. + * Only fields that define the event participate; `entryHash` itself is excluded. + * @param {object} entry + * @param {string} prevHash + * @returns {string} + */ +function computeEntryHash(entry, prevHash) { + return sha256({ + prevHash, + chainSeq: entry.chainSeq, + id: entry.id, + action: entry.action, + scope: entry.scope, + target: entry.target, + actor: entry.actor, + correlationId: entry.correlationId, + outcome: entry.outcome, + changes: entry.changes, + at: entry.at, + }); +} + +/** + * Derive a coarse scope from a dotted action (`transfer.created` → `transfers`). + * @param {string} action + * @returns {string} + */ +function scopeFromAction(action) { + const head = String(action || '').split('.')[0] || 'unknown'; + if (head === 'transfer') return 'transfers'; + if (head === 'user') return 'users'; + if (head === 'admin') return 'admin'; + return `${head}s`; +} + +module.exports = { + GENESIS_HASH, + REDACTED, + SENSITIVE_KEY_PATTERN, + actorRef, + canonicalize, + computeEntryHash, + redact, + scopeFromAction, + sha256, +}; diff --git a/test/auditIntegrity.test.js b/test/auditIntegrity.test.js new file mode 100644 index 0000000..4c201bc --- /dev/null +++ b/test/auditIntegrity.test.js @@ -0,0 +1,375 @@ +'use strict'; + +/** + * Tamper-evident, queryable audit storage (issue #127). + * + * Covers: + * - integrity-chain verification and tamper detection (original failure mode) + * - redaction of secrets from stored / returned changes + * - duplicate-event suppression for the same privileged mutation + * - query authorization (audit:read scope) + * - correlation / attribution filters without exposing secrets + */ + +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.NODE_ENV = 'test'; + +const createApp = require('../src/app'); +const { reset } = require('../src/store'); +const auditService = require('../src/services/auditService'); +const transferService = require('../src/services/transferService'); +const { REDACTED, computeEntryHash, GENESIS_HASH } = require('../src/utils/auditCrypto'); + +let server; +let baseUrl; + +before(() => { + const app = createApp(); + return new Promise((resolve) => { + server = app.listen(0, () => { + const { port } = server.address(); + baseUrl = `http://127.0.0.1:${port}`; + resolve(); + }); + }); +}); + +after(() => { + if (server) server.close(); +}); + +beforeEach(() => { + reset(); +}); + +function auth(token) { + return { Authorization: `Bearer ${token}` }; +} + +async function fetchJson(path, options = {}) { + const res = await fetch(`${baseUrl}${path}`, options); + let body; + try { + body = await res.json(); + } catch { + body = null; + } + return { status: res.status, body }; +} + +const TRANSFER = { + senderName: 'Alice', + recipientName: 'Bob', + amount: 50, + from: 'USD', + to: 'INR', +}; + +// ─── Integrity chain ────────────────────────────────────────────────────────── + +test('integrity-chain: successive entries link via prevHash and verify clean', () => { + const a = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-1', + requestId: 'req-1', + actor: 'test-token-admin', + }); + const b = auditService.addEntry({ + action: 'transfer.claimed', + resourceId: 'txn-1', + requestId: 'req-2', + actor: 'test-token-admin', + }); + + assert.equal(a.chainSeq, 0); + assert.equal(a.prevHash, GENESIS_HASH); + assert.equal(b.chainSeq, 1); + assert.equal(b.prevHash, a.entryHash); + assert.notEqual(a.entryHash, b.entryHash); + assert.equal(a.entryHash, computeEntryHash(a, GENESIS_HASH)); + + const report = auditService.verifyIntegrity(); + assert.equal(report.valid, true); + assert.equal(report.checked, 2); + assert.equal(report.tipHash, b.entryHash); + assert.equal(report.brokenAt, null); +}); + +test('REGRESSION: silent field edit on an audit record is detectable', () => { + // Original failure mode: an investigator cannot trust an audit trail if a + // record can be changed without leaving evidence. Mutating a stored field + // must break the hash chain. + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-tamper', + payload: { sendAmount: 10 }, + requestId: 'req-tamper', + actor: 'test-token-admin', + }); + auditService.addEntry({ + action: 'transfer.cancelled', + resourceId: 'txn-tamper', + requestId: 'req-tamper-2', + actor: 'test-token-admin', + }); + + assert.equal(auditService.verifyIntegrity().valid, true); + + const [newest] = auditService.getEntries(); + newest.action = 'transfer.claimed'; // silent rewrite + + const report = auditService.verifyIntegrity(); + assert.equal(report.valid, false); + assert.equal(report.brokenAt, newest.chainSeq); + assert.match(report.reason, /entryHash mismatch/); +}); + +test('REGRESSION: broken predecessor link is detectable', () => { + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-a', + requestId: 'req-a', + }); + const second = auditService.addEntry({ + action: 'transfer.claimed', + resourceId: 'txn-a', + requestId: 'req-b', + }); + + second.prevHash = GENESIS_HASH; // pretend the first entry never happened + + const report = auditService.verifyIntegrity(); + assert.equal(report.valid, false); + assert.match(report.reason, /prevHash mismatch/); +}); + +// ─── Redaction ──────────────────────────────────────────────────────────────── + +test('redaction: sensitive keys are stripped from stored changes', () => { + const entry = auditService.addEntry({ + action: 'admin.config_changed', + resourceId: 'cfg-1', + requestId: 'req-redact', + actor: 'test-token-admin', + changes: { + feePercent: 1.5, + apiKey: 'super-secret-key', + nested: { token: 'abc', safe: true }, + authorization: 'Bearer leaked', + }, + }); + + assert.equal(entry.changes.feePercent, 1.5); + assert.equal(entry.changes.apiKey, REDACTED); + assert.equal(entry.changes.nested.token, REDACTED); + assert.equal(entry.changes.nested.safe, true); + assert.equal(entry.changes.authorization, REDACTED); + // Compat alias stays in sync with the redacted view. + assert.deepEqual(entry.payload, entry.changes); + // Raw secret must not survive anywhere on the entry. + assert.equal(JSON.stringify(entry).includes('super-secret-key'), false); +}); + +test('redaction: HTTP list responses never expose secrets', async () => { + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-secret', + requestId: 'req-secret', + actor: 'test-token-admin', + changes: { sendAmount: 42, secret: 'should-not-leak', password: 'nope' }, + }); + + const { status, body } = await fetchJson('/api/audit?resourceId=txn-secret', { + headers: auth('test-token-admin'), + }); + + assert.equal(status, 200); + assert.equal(body.entries.length, 1); + assert.equal(body.entries[0].changes.secret, REDACTED); + assert.equal(body.entries[0].changes.password, REDACTED); + assert.equal(body.entries[0].changes.sendAmount, 42); + assert.equal(JSON.stringify(body).includes('should-not-leak'), false); +}); + +// ─── Duplicate-event suppression ────────────────────────────────────────────── + +test('duplicate-event: same action/target/correlation/outcome records once', () => { + const first = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-dup', + requestId: 'corr-dup', + actor: 'test-token-admin', + outcome: 'success', + payload: { sendAmount: 1 }, + }); + const second = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-dup', + requestId: 'corr-dup', + actor: 'test-token-admin', + outcome: 'success', + payload: { sendAmount: 99 }, + }); + + assert.equal(second.id, first.id); + assert.equal(auditService.getEntries().length, 1); + assert.equal(first.changes.sendAmount, 1, 'first-write wins; retry must not rewrite'); + assert.equal(auditService.verifyIntegrity().valid, true); +}); + +test('duplicate-event: a different correlation id still appends', () => { + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-dup-2', + requestId: 'corr-1', + outcome: 'success', + }); + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-dup-2', + requestId: 'corr-2', + outcome: 'success', + }); + assert.equal(auditService.getEntries().length, 2); +}); + +test('duplicate-event: privileged transfer create emits one outcome under retry', () => { + const first = transferService.createTransfer(TRANSFER, 'corr-idem', { + actor: 'test-token-admin', + key: 'idem-audit-1', + fingerprint: 'fp-audit-1', + }); + // Force a second audit attempt with the same correlation id (simulates a + // client that retried past the idempotency layer and hit addEntry again). + const replayed = auditService.addEntry({ + action: 'transfer.created', + resourceId: first.id, + requestId: 'corr-idem', + actor: 'test-token-admin', + outcome: 'success', + }); + + const created = auditService.getEntries().filter((e) => e.action === 'transfer.created'); + assert.equal(created.length, 1); + assert.equal(replayed.id, created[0].id); +}); + +// ─── Query authorization ────────────────────────────────────────────────────── + +test('query authorization: missing token is 401', async () => { + const { status, body } = await fetchJson('/api/audit'); + assert.equal(status, 401); + assert.equal(body.error.status, 401); +}); + +test('query authorization: token without audit:read is 403', async () => { + const { status, body } = await fetchJson('/api/audit', { + headers: auth('test-token-transfers'), + }); + assert.equal(status, 403); + assert.equal(body.error.status, 403); +}); + +test('query authorization: integrity endpoint requires audit:read', async () => { + const denied = await fetchJson('/api/audit/integrity', { + headers: auth('test-token-transfers'), + }); + assert.equal(denied.status, 403); + + const allowed = await fetchJson('/api/audit/integrity', { + headers: auth('test-token-readonly'), + }); + assert.equal(allowed.status, 200); + assert.equal(allowed.body.valid, true); + assert.equal(typeof allowed.body.tipHash, 'string'); +}); + +// ─── Correlation / attribution filters ──────────────────────────────────────── + +test('correlation: entries carry actor fingerprint, scope, outcome, correlationId', () => { + const entry = auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-corr', + requestId: 'corr-xyz', + actor: 'test-token-admin', + outcome: 'success', + payload: { sendAmount: 5 }, + }); + + assert.equal(entry.correlationId, 'corr-xyz'); + assert.equal(entry.requestId, 'corr-xyz'); + assert.equal(entry.target, 'txn-corr'); + assert.equal(entry.resourceId, 'txn-corr'); + assert.equal(entry.scope, 'transfers'); + assert.equal(entry.outcome, 'success'); + assert.match(entry.actor, /^actor:[0-9a-f]{16}$/); + assert.equal(entry.actor.includes('test-token-admin'), false); +}); + +test('correlation: operators can filter by correlationId and scope', async () => { + auditService.addEntry({ + action: 'transfer.created', + resourceId: 'txn-f1', + requestId: 'corr-filter', + actor: 'test-token-admin', + }); + auditService.addEntry({ + action: 'user.created', + resourceId: 'usr-f1', + requestId: 'corr-other', + actor: 'test-token-admin', + }); + auditService.addEntry({ + action: 'transfer.claimed', + resourceId: 'txn-f1', + requestId: 'corr-filter', + actor: 'test-token-readonly', + }); + + const byCorr = await fetchJson('/api/audit?correlationId=corr-filter', { + headers: auth('test-token-admin'), + }); + assert.equal(byCorr.status, 200); + assert.equal(byCorr.body.entries.length, 2); + assert.ok(byCorr.body.entries.every((e) => e.correlationId === 'corr-filter')); + + const byScope = await fetchJson('/api/audit?scope=users', { + headers: auth('test-token-admin'), + }); + assert.equal(byScope.status, 200); + assert.equal(byScope.body.entries.length, 1); + assert.equal(byScope.body.entries[0].action, 'user.created'); + + // Actor fingerprints differ across tokens; filtering by one excludes the other. + const adminActor = auditService.getEntries().find((e) => e.action === 'transfer.created').actor; + const byActor = await fetchJson(`/api/audit?actor=${encodeURIComponent(adminActor)}`, { + headers: auth('test-token-admin'), + }); + assert.equal(byActor.status, 200); + assert.ok(byActor.body.entries.length >= 1); + assert.ok(byActor.body.entries.every((e) => e.actor === adminActor)); + assert.equal(JSON.stringify(byActor.body).includes('test-token-admin'), false); +}); + +test('privileged archive/unarchive mutations each emit one outcome event', () => { + const transfer = transferService.createTransfer(TRANSFER, 'corr-arch-create', { + actor: 'test-token-admin', + key: 'idem-arch', + fingerprint: 'fp-arch', + }); + + transferService.archiveTransfer(transfer.id, 'corr-arch', 'test-token-admin'); + transferService.archiveTransfer(transfer.id, 'corr-arch', 'test-token-admin'); // idempotent no-op + + const archived = auditService.getEntries().filter((e) => e.action === 'transfer.archived'); + assert.equal(archived.length, 1); + assert.equal(archived[0].outcome, 'success'); + assert.equal(archived[0].correlationId, 'corr-arch'); + + transferService.unarchiveTransfer(transfer.id, 'corr-unarch', 'test-token-admin'); + const unarchived = auditService.getEntries().filter((e) => e.action === 'transfer.unarchived'); + assert.equal(unarchived.length, 1); + assert.equal(auditService.verifyIntegrity().valid, true); +});