Skip to content

Commit 099c22a

Browse files
committed
Redact protected config values in argv logs
1 parent b97edc0 commit 099c22a

9 files changed

Lines changed: 168 additions & 42 deletions

File tree

lib/commands/config.js

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,7 @@ const { defaults, definitions, nerfDarts, proxyEnv } = require('@npmcli/config/l
88
const { log, output, input } = require('proc-log')
99
const BaseCommand = require('../base-cmd.js')
1010
const { redact } = require('@npmcli/redact')
11-
12-
// These are the config values to swap with "protected".
13-
// It does not catch every single sensitive thing a user may put in the npmrc file but it gets the common ones.
14-
// This is distinct from nerfDarts because that is used to validate valid configs during "npm config set", and folks may have old invalid entries lying around in a config file that we still want to protect when running "npm config list"
15-
// This is a more general list of values to consider protected.
16-
// You cannot "npm config get" them, and they will not display during "npm config list"
17-
const protected = [
18-
'auth',
19-
'authToken',
20-
'certfile',
21-
'email',
22-
'keyfile',
23-
'password',
24-
'username',
25-
]
11+
const { isProtected } = require('../utils/protected-config.js')
2612

2713
// take an array of `[key, value, k2=v2, k3, v3, ...]` and turn into { key: value, k2: v2, k3: v3 }
2814
const keyValues = args => {
@@ -38,29 +24,6 @@ const keyValues = args => {
3824
return kv
3925
}
4026

41-
const isProtected = (k) => {
42-
// _password
43-
if (k.startsWith('_')) {
44-
return true
45-
}
46-
if (protected.includes(k)) {
47-
return true
48-
}
49-
// //localhost:8080/:_password
50-
if (k.startsWith('//')) {
51-
if (k.includes(':_')) {
52-
return true
53-
}
54-
// //registry:_authToken or //registry:authToken
55-
for (const p of protected) {
56-
if (k.endsWith(`:${p}`) || k.endsWith(`:_${p}`)) {
57-
return true
58-
}
59-
}
60-
}
61-
return false
62-
}
63-
6427
// Private fields are either protected or they can redacted info
6528
const isPrivate = (k, v) => isProtected(k) || redact(v) !== v
6629

lib/npm.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const { log, time, output, META } = require('proc-log')
1010
const { redactLog: replaceInfo } = require('@npmcli/redact')
1111
const pkg = require('../package.json')
1212
const { deref } = require('./utils/cmd-list.js')
13+
const { cleanArgv } = require('./utils/clean-argv.js')
1314
const { jsonError, outputError } = require('./utils/output-error.js')
1415

1516
class Npm {
@@ -142,8 +143,7 @@ class Npm {
142143
process.title = this.#title
143144
// The cooked argv is also logged separately for debugging purposes.
144145
// It is cleaned as a best effort by replacing known secrets like basic auth password and strings that look like npm tokens.
145-
// XXX: for this to be safer the config should create a sanitized version of the argv as it has the full context of what each option contains.
146-
this.#argvClean = replaceInfo(cooked)
146+
this.#argvClean = cleanArgv(cooked)
147147
log.verbose('title', this.title)
148148
log.verbose('argv', this.#argvClean.map(JSON.stringify).join(' '))
149149
})

lib/utils/clean-argv.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
const { redactLog: replaceInfo } = require('@npmcli/redact')
2+
const { isProtected } = require('./protected-config.js')
3+
4+
const REDACTED = '***'
5+
const isProtectedArg = key => isProtected(key) || key === 'otp'
6+
7+
const cleanArgv = (argv) => {
8+
const cleaned = replaceInfo(argv)
9+
for (let i = 0; i < cleaned.length; i++) {
10+
const arg = cleaned[i]
11+
if (typeof arg !== 'string' || !arg.startsWith('--')) {
12+
continue
13+
}
14+
15+
const raw = arg.slice(2)
16+
const equalsIndex = raw.indexOf('=')
17+
const key = equalsIndex === -1 ? raw : raw.slice(0, equalsIndex)
18+
if (!isProtectedArg(key)) {
19+
continue
20+
}
21+
22+
if (equalsIndex === -1) {
23+
if (i + 1 < cleaned.length) {
24+
cleaned[i + 1] = REDACTED
25+
}
26+
} else {
27+
cleaned[i] = `${arg.slice(0, equalsIndex + 3)}${REDACTED}`
28+
}
29+
}
30+
return cleaned
31+
}
32+
33+
module.exports = {
34+
cleanArgv,
35+
}

lib/utils/error-message.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const { format } = require('node:util')
22
const { resolve } = require('node:path')
33
const { redactLog: replaceInfo } = require('@npmcli/redact')
4+
const { cleanArgv } = require('./clean-argv.js')
45
const { log } = require('proc-log')
56

67
const errorMessage = (er, npm) => {
@@ -352,7 +353,7 @@ const errorMessage = (er, npm) => {
352353
detail.push(['signal', er.signal])
353354
}
354355
if (er.cmd && Array.isArray(er.args)) {
355-
detail.push(['command', ...[er.cmd, ...er.args.map(replaceInfo)]])
356+
detail.push(['command', ...[er.cmd, ...cleanArgv(er.args)]])
356357
}
357358
if (er.stdout) {
358359
detail.push(['', er.stdout.trim()])

lib/utils/protected-config.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// These are config values whose contents should not be displayed.
2+
// This is intentionally broader than nerfDarts because users may have old or
3+
// invalid auth entries that still need to be protected when npm reports config.
4+
const protected = [
5+
'auth',
6+
'authToken',
7+
'certfile',
8+
'email',
9+
'keyfile',
10+
'password',
11+
'username',
12+
]
13+
14+
const isProtected = (key) => {
15+
// _password
16+
if (key.startsWith('_')) {
17+
return true
18+
}
19+
if (protected.includes(key)) {
20+
return true
21+
}
22+
// //localhost:8080/:_password
23+
if (key.startsWith('//')) {
24+
if (key.includes(':_')) {
25+
return true
26+
}
27+
// //registry:authToken or //registry:_authToken
28+
for (const protectedKey of protected) {
29+
if (key.endsWith(`:${protectedKey}`) || key.endsWith(`:_${protectedKey}`)) {
30+
return true
31+
}
32+
}
33+
}
34+
return false
35+
}
36+
37+
module.exports = {
38+
isProtected,
39+
}

tap-snapshots/test/lib/utils/error-message.js.test.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ Object {
195195
"g",
196196
"s",
197197
"https://evil:***@npmjs.org",
198+
"--//registry.npmjs.org/:_authToken",
199+
"***",
198200
],
199201
Array [
200202
"",

test/lib/cli/entry.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,31 @@ t.test('logged argv is sanitized with equals', async t => {
105105
t.match(logs.verbose.byTitle('argv'), ['argv "version" "--registry" "https://u:***@npmjs.org"'])
106106
})
107107

108+
t.test('logged argv redacts protected config values', async t => {
109+
const { logs, cli } = await cliMock(t, {
110+
globals: {
111+
'process.argv': [
112+
'node',
113+
'npm',
114+
'version',
115+
'--//registry.npmjs.org/:_authToken=plain-secret',
116+
'--_password',
117+
'hunter2',
118+
'--otp=123456',
119+
],
120+
},
121+
})
122+
await cli(process)
123+
124+
const argv = logs.verbose.byTitle('argv')[0]
125+
t.notMatch(argv, 'plain-secret')
126+
t.notMatch(argv, 'hunter2')
127+
t.notMatch(argv, '123456')
128+
t.match(argv, '"--//registry.npmjs.org/:_authToken" "***"')
129+
t.match(argv, '"--_password" "***"')
130+
t.match(argv, '"--otp" "***"')
131+
})
132+
108133
t.test('print usage if no params provided', async t => {
109134
const { cli, outputs, exitHandlerCalled, exitHandlerNpm } = await cliMock(t, {
110135
globals: {

test/lib/utils/clean-argv.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
const t = require('tap')
2+
const { cleanArgv } = require('../../../lib/utils/clean-argv.js')
3+
4+
t.test('redacts protected config values', t => {
5+
t.strictSame(cleanArgv([
6+
'--password=secret',
7+
'--otp',
8+
'123456',
9+
'--//registry.npmjs.org/:_authToken',
10+
'plain-secret',
11+
'--registry',
12+
'https://user:pass@registry.npmjs.org',
13+
]), [
14+
'--password=***',
15+
'--otp',
16+
'***',
17+
'--//registry.npmjs.org/:_authToken',
18+
'***',
19+
'--registry',
20+
'https://user:***@registry.npmjs.org',
21+
])
22+
t.end()
23+
})
24+
25+
t.test('redacts protected config values with equals', t => {
26+
t.strictSame(cleanArgv([
27+
'--otp=123456',
28+
'--_authToken=plain-secret',
29+
'--//registry.npmjs.org/:_password=hunter2',
30+
]), [
31+
'--otp=***',
32+
'--_authToken=***',
33+
'--//registry.npmjs.org/:_password=***',
34+
])
35+
t.end()
36+
})
37+
38+
t.test('leaves non-protected config values intact', t => {
39+
t.strictSame(cleanArgv([
40+
'--registry',
41+
'https://registry.npmjs.org',
42+
'--auth-type',
43+
'legacy',
44+
'--scope=@npmcli',
45+
]), [
46+
'--registry',
47+
'https://registry.npmjs.org',
48+
'--auth-type',
49+
'legacy',
50+
'--scope=@npmcli',
51+
])
52+
t.end()
53+
})

test/lib/utils/error-message.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,15 @@ t.test('args are cleaned', async t => {
167167
t.matchSnapshot(errorMessage(Object.assign(new Error('cmd err'), {
168168
cmd: 'some command',
169169
signal: 'SIGYOLO',
170-
args: ['a', 'r', 'g', 's', 'https://evil:password@npmjs.org'],
170+
args: [
171+
'a',
172+
'r',
173+
'g',
174+
's',
175+
'https://evil:password@npmjs.org',
176+
'--//registry.npmjs.org/:_authToken',
177+
'plain-secret',
178+
],
171179
stdout: 'stdout',
172180
stderr: 'stderr',
173181
})))

0 commit comments

Comments
 (0)