diff --git a/README.md b/README.md index 615a64d..720612f 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ const processFromQueue = (n) => { 1. For the logs where `log.kind` is missing, first 40 chars of dynamic_data is populated to uniquely identify each log in a restricted fashion. 2. For the logs where no contexts are set, ie. server, process or worker are not set, server format category and version is populated. 3. `request.request_id` and the `request_start_time` can be set manually if required. +4. Credential-bearing request headers (`authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`, `proxy-authorization`, `www-authenticate`) are logged as `[REDACTED]`. The header name is kept so the log still shows it was sent. Add your own with `Chitragupta.extendSensitiveHeaders(['x-tenant-secret'])`. ## Contributing diff --git a/lib/chitragupta/chitragupta.js b/lib/chitragupta/chitragupta.js index 9aa8a88..a88b7ff 100644 --- a/lib/chitragupta/chitragupta.js +++ b/lib/chitragupta/chitragupta.js @@ -105,4 +105,5 @@ module.exports = { getUniqueLogId, jsonLogFormatter, setMetaData, + extendSensitiveHeaders: util.extendSensitiveHeaders, }; diff --git a/lib/chitragupta/util.js b/lib/chitragupta/util.js index 29f17ed..e97a45a 100644 --- a/lib/chitragupta/util.js +++ b/lib/chitragupta/util.js @@ -4,6 +4,45 @@ const formatVersions = require('./format_versions'); const fieldLimits = require('./field_limits'); const os = require('os'); +// Sensitive headers that must never reach the log sink as cleartext values. +// Closes DATA-12974 (Authorization/Cookie/etc serialised verbatim) and weakens +// DATA-13003/13004 chains by removing the secret material at the source. +// Names are matched case-insensitively against the request header keys. +const DEFAULT_SENSITIVE_HEADERS = new Set([ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'proxy-authorization', + 'www-authenticate', +]); + +const customSensitiveHeaders = new Set(); + +function extendSensitiveHeaders(headerNames) { + if (!Array.isArray(headerNames)) return; + headerNames.forEach((h) => { + if (typeof h === 'string' && h.length) { + customSensitiveHeaders.add(h.toLowerCase()); + } + }); +} + +function isSensitiveHeader(name) { + const lower = String(name).toLowerCase(); + return DEFAULT_SENSITIVE_HEADERS.has(lower) || customSensitiveHeaders.has(lower); +} + +function redactHeaders(headers) { + if (!headers || typeof headers !== 'object') return headers; + const out = {}; + Object.keys(headers).forEach((key) => { + out[key] = isSensitiveHeader(key) ? '[REDACTED]' : headers[key]; + }); + return out; +} + function populateServerData(dataParam) { const data = dataParam; const request = cls.get('request'); @@ -17,7 +56,7 @@ function populateServerData(dataParam) { const indexOfQuestionMark = url.indexOf('?'); let endpoint = ''; let params = ''; - let headers = JSON.stringify(request.headers); + let headers = JSON.stringify(redactHeaders(request.headers)); if (indexOfQuestionMark > 0) { endpoint = url.slice(0, indexOfQuestionMark); params = url.slice(indexOfQuestionMark + 1); @@ -40,7 +79,7 @@ function populateServerData(dataParam) { data.data.request.params = JSON.stringify(data.data.request.params); } if (data.data.request.headers && typeof data.data.request.headers === 'object') { - data.data.request.headers = JSON.stringify(data.data.request.headers); + data.data.request.headers = JSON.stringify(redactHeaders(data.data.request.headers)); } data.data.request.params = data.data.request.params.substring(0, fieldLimits.PARAMS) || ''; @@ -135,3 +174,4 @@ function sanitizeKeys(logLevel, message, metaData) { } module.exports.sanitizeKeys = sanitizeKeys; +module.exports.extendSensitiveHeaders = extendSensitiveHeaders; diff --git a/package.json b/package.json index fadf2ef..60ebcc5 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "chitragupta", - "version": "1.7.6", + "version": "1.7.7", "description": "An easy to install node module to convert unstructured logs into informative structured logs", "main": "lib/index.js", "scripts": { - "test": "node test/context.js" + "test": "node test/context.js && node test/headers.js" }, "repository": { "type": "git", diff --git a/test/headers.js b/test/headers.js new file mode 100644 index 0000000..b96978b --- /dev/null +++ b/test/headers.js @@ -0,0 +1,65 @@ +const assert = require('assert'); +const EventEmitter = require('events'); +const { Chitragupta } = require('../lib'); + +function response() { + const res = new EventEmitter(); + res.finished = true; + res.statusCode = 200; + res.duration = 1; + return res; +} + +function formatted(headers) { + let record; + Chitragupta.setupServerLogger( + { log() {} }, + { url: '/api/resource?page=2', method: 'GET', headers }, + response(), + 'user-1', + () => { + record = JSON.parse(Chitragupta.jsonLogFormatter({ + level: 'info', + message: 'Done', + meta: { log: { kind: 'HEADER_TEST' } }, + })); + }, + ); + return JSON.parse(record.data.request.headers); +} + +function testSensitiveHeadersAreRedacted() { + const logged = formatted({ + host: 'app.example.com', + authorization: 'Bearer secret-token', + cookie: 'session=secret-session', + 'x-api-key': 'secret-key', + }); + + assert.strictEqual(logged.authorization, '[REDACTED]'); + assert.strictEqual(logged.cookie, '[REDACTED]'); + assert.strictEqual(logged['x-api-key'], '[REDACTED]'); + assert.strictEqual(logged.host, 'app.example.com'); +} + +function testMatchIsCaseInsensitive() { + assert.strictEqual(formatted({ Authorization: 'Bearer secret-token' }).Authorization, '[REDACTED]'); +} + +function testRequestHeadersAreNotMutated() { + const headers = { authorization: 'Bearer secret-token' }; + formatted(headers); + assert.strictEqual(headers.authorization, 'Bearer secret-token'); +} + +function testHostsCanExtendTheDenylist() { + assert.strictEqual(formatted({ 'x-tenant-secret': 'secret' })['x-tenant-secret'], 'secret'); + Chitragupta.extendSensitiveHeaders(['X-Tenant-Secret']); + assert.strictEqual(formatted({ 'x-tenant-secret': 'secret' })['x-tenant-secret'], '[REDACTED]'); +} + +testSensitiveHeadersAreRedacted(); +testMatchIsCaseInsensitive(); +testRequestHeadersAreNotMutated(); +testHostsCanExtendTheDenylist(); +process.stdout.write('header redaction tests passed\n');