diff --git a/README.md b/README.md index 720612f..2ed1a65 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ const processFromQueue = (n) => { 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'])`. +5. Credential-bearing query parameters (`access_token`, `refresh_token`, `id_token`, `token`, `api_key`, `apikey`, `api_token`, `private_token`, `client_secret`, `secret`, `password`, `signature`, `x-amz-signature`, `x-amz-credential`, `x-amz-security-token`) are logged as `[REDACTED]` in `data.request.params`, keeping the parameter name. Add your own with `Chitragupta.extendSensitiveParams(['code'])`. +6. `data.request.endpoint` and `params` come from `request.originalUrl` when present (Express), so routes mounted on a sub-router log their full path. ## Contributing diff --git a/lib/chitragupta/chitragupta.js b/lib/chitragupta/chitragupta.js index a88b7ff..efeb422 100644 --- a/lib/chitragupta/chitragupta.js +++ b/lib/chitragupta/chitragupta.js @@ -106,4 +106,5 @@ module.exports = { jsonLogFormatter, setMetaData, extendSensitiveHeaders: util.extendSensitiveHeaders, + extendSensitiveParams: util.extendSensitiveParams, }; diff --git a/lib/chitragupta/util.js b/lib/chitragupta/util.js index cb9c8cb..4b12481 100644 --- a/lib/chitragupta/util.js +++ b/lib/chitragupta/util.js @@ -34,6 +34,66 @@ function isSensitiveHeader(name) { return DEFAULT_SENSITIVE_HEADERS.has(lower) || customSensitiveHeaders.has(lower); } +// Query parameters that carry credentials (OAuth tokens, API keys, signed-URL signatures). +// Matched case-insensitively against the decoded parameter name; the name is kept and the +// value is logged as [REDACTED]. +const DEFAULT_SENSITIVE_PARAMS = new Set([ + 'access_token', + 'refresh_token', + 'id_token', + 'token', + 'api_key', + 'apikey', + 'api_token', + 'private_token', + 'client_secret', + 'secret', + 'password', + 'signature', + 'x-amz-signature', + 'x-amz-credential', + 'x-amz-security-token', +]); + +const customSensitiveParams = new Set(); + +function extendSensitiveParams(paramNames) { + if (!Array.isArray(paramNames)) return; + paramNames.forEach((p) => { + if (typeof p === 'string' && p.length) { + customSensitiveParams.add(p.toLowerCase()); + } + }); +} + +function isSensitiveParam(name) { + let decoded = String(name); + try { + decoded = decodeURIComponent(decoded.replace(/\+/g, ' ')); + } catch (error) { + // Malformed escapes: match on the raw name. + } + const lower = decoded.toLowerCase(); + return DEFAULT_SENSITIVE_PARAMS.has(lower) || customSensitiveParams.has(lower); +} + +// Accepts the raw query string (`a=1&token=x`) or a params object set by the host. +function redactParams(params) { + if (params && typeof params === 'object' && !Array.isArray(params)) { + const out = {}; + Object.keys(params).forEach((key) => { + out[key] = isSensitiveParam(key) ? '[REDACTED]' : params[key]; + }); + return out; + } + if (typeof params !== 'string' || !params.length) return params; + return params.split('&').map((pair) => { + const separator = pair.indexOf('='); + if (separator < 0) return pair; + return isSensitiveParam(pair.slice(0, separator)) ? `${pair.slice(0, separator)}=[REDACTED]` : pair; + }).join('&'); +} + function redactHeaders(headers) { if (!headers || typeof headers !== 'object') return headers; const out = {}; @@ -52,7 +112,8 @@ function populateServerData(dataParam) { data.log.uuid = cls.get("uuid") || ""; - const { url } = request; + // Express rewrites `url` relative to the router mount point; `originalUrl` is the full path. + const url = request.originalUrl || request.url || ''; const indexOfQuestionMark = url.indexOf('?'); let endpoint = ''; let params = ''; @@ -72,7 +133,7 @@ function populateServerData(dataParam) { // data.data.request.ip = "TODO"; data.data.request.id = data.data.request.id || request.request_id; data.data.request.user_id = data.data.request.user_id || cls.get('userId'); - data.data.request.params = data.data.request.params || params; + data.data.request.params = redactParams(data.data.request.params || params); let headers = data.data.request.headers || request.headers || {}; if (data.data.request.params && typeof data.data.request.params === 'object') { data.data.request.params = JSON.stringify(data.data.request.params); @@ -180,3 +241,4 @@ function sanitizeKeys(logLevel, message, metaData) { module.exports.sanitizeKeys = sanitizeKeys; module.exports.extendSensitiveHeaders = extendSensitiveHeaders; +module.exports.extendSensitiveParams = extendSensitiveParams; diff --git a/package.json b/package.json index 60ebcc5..4f9a2d9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,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 && node test/headers.js" + "test": "node test/context.js && node test/headers.js && node test/params.js" }, "repository": { "type": "git", diff --git a/test/params.js b/test/params.js new file mode 100644 index 0000000..4bc448b --- /dev/null +++ b/test/params.js @@ -0,0 +1,76 @@ +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(request, meta = {}) { + let record; + Chitragupta.setupServerLogger( + { log() {} }, + { method: 'GET', headers: {}, ...request }, + response(), + 'user-1', + () => { + record = JSON.parse(Chitragupta.jsonLogFormatter({ + level: 'info', + message: 'Done', + meta: { log: { kind: 'PARAMS_TEST' }, ...meta }, + })); + }, + ); + return record.data.request; +} + +function testSensitiveParamsAreRedacted() { + const logged = formatted({ url: '/api/items?page=2&access_token=fixture-a&API_KEY=fixture-b&tool=jira' }); + + assert.strictEqual(logged.params, 'page=2&access_token=[REDACTED]&API_KEY=[REDACTED]&tool=jira'); + assert.strictEqual(logged.endpoint, '/api/items'); +} + +function testEncodedParamNamesAreMatched() { + const logged = formatted({ url: '/api/items?X-Amz-Signature=fixture-c&client%5Fsecret=fixture-d' }); + assert.strictEqual(logged.params, 'X-Amz-Signature=[REDACTED]&client%5Fsecret=[REDACTED]'); +} + +function testParamsWithoutValuesAreKept() { + assert.strictEqual(formatted({ url: '/api/items?flag&token=fixture-e' }).params, 'flag&token=[REDACTED]'); +} + +function testHostSuppliedParamsObjectIsRedacted() { + const logged = formatted({ url: '/api/items' }, { data: { request: { params: { token: 'fixture-f', page: 2 } } } }); + assert.deepStrictEqual(JSON.parse(logged.params), { token: '[REDACTED]', page: 2 }); +} + +function testHostsCanExtendTheDenylist() { + assert.strictEqual(formatted({ url: '/oauth/callback?code=fixture-g&state=s' }).params, 'code=fixture-g&state=s'); + Chitragupta.extendSensitiveParams(['Code']); + assert.strictEqual(formatted({ url: '/oauth/callback?code=fixture-g&state=s' }).params, 'code=[REDACTED]&state=s'); +} + +function testOriginalUrlIsPreferred() { + // Express sets `url` relative to the router mount point and keeps the full path in `originalUrl`. + const logged = formatted({ url: '/items?page=2', originalUrl: '/api/v1/items?page=2&token=fixture-h' }); + assert.strictEqual(logged.endpoint, '/api/v1/items'); + assert.strictEqual(logged.params, 'page=2&token=[REDACTED]'); +} + +function testUrlIsUsedWithoutOriginalUrl() { + assert.strictEqual(formatted({ url: '/plain/path' }).endpoint, '/plain/path'); +} + +testSensitiveParamsAreRedacted(); +testEncodedParamNamesAreMatched(); +testParamsWithoutValuesAreKept(); +testHostSuppliedParamsObjectIsRedacted(); +testHostsCanExtendTheDenylist(); +testOriginalUrlIsPreferred(); +testUrlIsUsedWithoutOriginalUrl(); +process.stdout.write('query param redaction tests passed\n');