Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/chitragupta/chitragupta.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,5 @@ module.exports = {
jsonLogFormatter,
setMetaData,
extendSensitiveHeaders: util.extendSensitiveHeaders,
extendSensitiveParams: util.extendSensitiveParams,
};
66 changes: 64 additions & 2 deletions lib/chitragupta/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
Expand All @@ -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 = '';
Expand All @@ -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);
Expand Down Expand Up @@ -180,3 +241,4 @@ function sanitizeKeys(logLevel, message, metaData) {

module.exports.sanitizeKeys = sanitizeKeys;
module.exports.extendSensitiveHeaders = extendSensitiveHeaders;
module.exports.extendSensitiveParams = extendSensitiveParams;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions test/params.js
Original file line number Diff line number Diff line change
@@ -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');
Loading