Repository: pinojs/pino
Pull request #587 (2019-01-18 16:28:50)

Leading wildcard
Add support for redaction paths with leading wildcards to pino.

```
const log = require('pino').({ redact: ['*.password'] })
log.info( { user1: {name: 'Jack', password: 'passw0rd'}, user2: {name: 'Jill', password: 'pass123'} }, 'People' )
```

will output a log message with both Jack and Jill's passwords redacted.

See Jan 19, 2019 discussion in https://gitter.im/pinojs/pino


jsumners: Oh, looks like David already reviewed. Silly GitHub wasn't showing that to me.

Patch:
diff --git a/lib/redaction.js b/lib/redaction.js
index 760bf61..0d3ab05 100644
--- a/lib/redaction.js
+++ b/lib/redaction.js
@@ -1,7 +1,7 @@
 'use strict'
 
 const fastRedact = require('fast-redact')
-const { redactFmtSym } = require('./symbols')
+const { redactFmtSym, wildcardFirstSym } = require('./symbols')
 const { rx, validator } = fastRedact
 
 const validate = validator({
@@ -27,9 +27,31 @@ function redaction (opts, serialize) {
     const { index } = next
     const firstPosBracket = str[index - 1] === '['
     const leadingChar = firstPosBracket ? '[' : ''
-    const ns = str.substr(0, index - 1).replace(/^\["(.+)"\]$/, '$1')
+    const nextPath = `${leadingChar}${str.substr(index, str.length - 1)}`
+    var ns = str.substr(0, index - 1).replace(/^\["(.+)"\]$/, '$1')
+    if (ns === '*') {
+      ns = wildcardFirstSym
+    }
     o[ns] = o[ns] || []
-    o[ns].push(`${leadingChar}${str.substr(index, str.length - 1)}`)
+
+    // shape is a mix of paths beginning with literal values and wildcard
+    // paths [ "a.b.c", "*.b.z" ] should reduce to a shape of
+    // { "a": [ "b.c", "b.z" ], *: [ "b.z" ] }
+    // note: "b.z" is in both "a" and * arrays because "a" matches the wildcard.
+    // (* entry has wildcardFirstSym as key)
+    if (ns !== wildcardFirstSym && o[ns].length === 0) {
+      // first time ns's get all '*' redactions so far
+      o[ns].push(...(o[wildcardFirstSym] || []))
+    }
+
+    if (ns === wildcardFirstSym) {
+      // new * path gets added to all previously registered literal ns's.
+      Object.keys(o).forEach(function (k) {
+        o[k].push(nextPath)
+      })
+    }
+
+    o[ns].push(nextPath)
     return o
   }, {})
 
@@ -41,7 +63,7 @@ function redaction (opts, serialize) {
   }
   const serializedCensor = serialize(censor)
   const topCensor = () => serializedCensor
-  return Object.keys(shape).reduce((o, k) => {
+  return [...Object.keys(shape), ...Object.getOwnPropertySymbols(shape)].reduce((o, k) => {
     // top level key:
     if (shape[k] === null) o[k] = topCensor
     else o[k] = fastRedact({ paths: shape[k], censor, serialize, strict })
diff --git a/lib/symbols.js b/lib/symbols.js
index 8b8ff88..ea9328c 100644
--- a/lib/symbols.js
+++ b/lib/symbols.js
@@ -23,6 +23,8 @@ const endSym = Symbol('pino.end')
 const formatOptsSym = Symbol('pino.formatOpts')
 const messageKeyStringSym = Symbol('pino.messageKeyString')
 
+const wildcardFirstSym = Symbol('pino.wildcardFirst')
+
 // public symbols, no need to use the same pino
 // version for these
 const serializersSym = Symbol.for('pino.serializers')
@@ -48,6 +50,7 @@ module.exports = {
   endSym,
   formatOptsSym,
   messageKeyStringSym,
+  wildcardFirstSym,
   changeLevelNameSym,
   wildcardGsym,
   needsMetadataGsym,
diff --git a/lib/tools.js b/lib/tools.js
index f2c75bb..92d44bc 100644
--- a/lib/tools.js
+++ b/lib/tools.js
@@ -15,6 +15,7 @@ const {
   endSym,
   stringifiersSym,
   stringifySym,
+  wildcardFirstSym,
   needsMetadataGsym,
   wildcardGsym,
   redactFmtSym,
@@ -97,11 +98,14 @@ function asJson (obj, msg, num, time) {
     if (serializers[wildcardGsym]) {
       obj = serializers[wildcardGsym](obj)
     }
+    const wildcardStringifier = stringifiers[wildcardFirstSym]
     for (var key in obj) {
       value = obj[key]
       if ((notHasOwnProperty || obj.hasOwnProperty(key)) && value !== undefined) {
         value = serializers[key] ? serializers[key](value) : value
 
+        const stringifier = stringifiers[key] || wildcardStringifier
+
         switch (typeof value) {
           case 'undefined':
           case 'function':
@@ -113,14 +117,14 @@ function asJson (obj, msg, num, time) {
             }
             // this case explicity falls through to the next one
           case 'boolean':
-            if (stringifiers[key]) value = stringifiers[key](value)
+            if (stringifier) value = stringifier(value)
             data += ',"' + key + '":' + value
             continue
           case 'string':
-            value = (stringifiers[key] || asString)(value)
+            value = (stringifier || asString)(value)
             break
           default:
-            value = (stringifiers[key] || stringify)(value)
+            value = (stringifier || stringify)(value)
         }
         if (value === undefined) continue
         data += ',"' + key + '":' + value


Test patch:
diff --git a/test/redact.test.js b/test/redact.test.js
index 65ffaae..c69d49d 100644
--- a/test/redact.test.js
+++ b/test/redact.test.js
@@ -354,6 +354,71 @@ test('redact – supports last position wildcard paths', async ({ is }) => {
   is(req.headers.connection, '[Redacted]')
 })
 
+test('redact – supports first position wildcard paths', async ({ is }) => {
+  const stream = sink()
+  const instance = pino({ redact: ['*.headers'] }, stream)
+  instance.info({
+    req: {
+      id: 7915,
+      method: 'GET',
+      url: '/',
+      headers: {
+        host: 'localhost:3000',
+        connection: 'keep-alive',
+        cookie: 'SESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;'
+      },
+      remoteAddress: '::ffff:127.0.0.1',
+      remotePort: 58022
+    }
+  })
+  const { req } = await once(stream, 'data')
+  is(req.headers, '[Redacted]')
+})
+
+test('redact – supports first position wildcards before other paths', async ({ is }) => {
+  const stream = sink()
+  const instance = pino({ redact: ['*.headers.cookie', 'req.id'] }, stream)
+  instance.info({
+    req: {
+      id: 7915,
+      method: 'GET',
+      url: '/',
+      headers: {
+        host: 'localhost:3000',
+        connection: 'keep-alive',
+        cookie: 'SESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;'
+      },
+      remoteAddress: '::ffff:127.0.0.1',
+      remotePort: 58022
+    }
+  })
+  const { req } = await once(stream, 'data')
+  is(req.headers.cookie, '[Redacted]')
+  is(req.id, '[Redacted]')
+})
+
+test('redact – supports first position wildcards after other paths', async ({ is }) => {
+  const stream = sink()
+  const instance = pino({ redact: ['req.id', '*.headers.cookie'] }, stream)
+  instance.info({
+    req: {
+      id: 7915,
+      method: 'GET',
+      url: '/',
+      headers: {
+        host: 'localhost:3000',
+        connection: 'keep-alive',
+        cookie: 'SESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;'
+      },
+      remoteAddress: '::ffff:127.0.0.1',
+      remotePort: 58022
+    }
+  })
+  const { req } = await once(stream, 'data')
+  is(req.headers.cookie, '[Redacted]')
+  is(req.id, '[Redacted]')
+})
+
 test('redact – supports intermediate wildcard paths', async ({ is }) => {
   const stream = sink()
   const instance = pino({ redact: ['req.*.cookie'] }, stream)