diff --git a/bin/ncu-config.js b/bin/ncu-config.js index ffe50209..67e5545b 100755 --- a/bin/ncu-config.js +++ b/bin/ncu-config.js @@ -1,10 +1,14 @@ #!/usr/bin/env node +import * as readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; + import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { - getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG + getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG, + encryptValue } from '../lib/config.js'; import { setVerbosityFromEnv } from '../lib/verbosity.js'; @@ -13,10 +17,15 @@ setVerbosityFromEnv(); const args = yargs(hideBin(process.argv)) .completion('completion') .command({ - command: 'set ', + command: 'set []', desc: 'Set a config variable', builder: (yargs) => { yargs + .option('encrypt', { + describe: 'Store the value encrypted using gpg', + alias: 'x', + type: 'boolean' + }) .positional('key', { describe: 'key of the configuration', type: 'string' @@ -61,8 +70,6 @@ const args = yargs(hideBin(process.argv)) .conflicts('global', 'project') .help(); -const argv = args.parse(); - function getConfigType(argv) { if (argv.global) { return { configName: 'global', configType: GLOBAL_CONFIG }; @@ -73,9 +80,19 @@ function getConfigType(argv) { return { configName: 'local', configType: LOCAL_CONFIG }; } -function setHandler(argv) { +async function setHandler(argv) { const { configName, configType } = getConfigType(argv); const config = getConfig(configType); + if (!argv.value) { + const rl = readline.createInterface({ input, output }); + argv.value = await rl.question('What value do you want to set? '); + rl.close(); + } else if (argv.encrypt) { + console.warn('Passing sensitive config value via the shell is discouraged'); + } + if (argv.encrypt) { + argv.value = await encryptValue(argv.value); + } console.log( `Updating ${configName} configuration ` + `[${argv.key}]: ${config[argv.key]} -> ${argv.value}`); @@ -96,6 +113,8 @@ function listHandler(argv) { } } +const argv = await args.parse(); + if (!['get', 'set', 'list'].includes(argv._[0])) { args.showHelp(); } diff --git a/lib/auth.js b/lib/auth.js index ffabe13a..7c7e4bc8 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -3,7 +3,7 @@ import { ClientRequest } from 'node:http'; import ghauth from 'ghauth'; -import { clearCachedConfig, getMergedConfig, getNcurcPath } from './config.js'; +import { clearCachedConfig, encryptValue, getMergedConfig, getNcurcPath } from './config.js'; export default lazy(auth); @@ -83,7 +83,12 @@ async function auth( 'see https://github.com/nodejs/node-core-utils/blob/main/README.md.\n'); const credentials = await tryCreateGitHubToken(githubAuth); username = credentials.user; - token = credentials.token; + try { + token = await encryptValue(credentials.token); + } catch (err) { + console.warn('Failed encrypt token, storing unencrypted instead'); + token = credentials.token; + } const json = JSON.stringify({ username, token }, null, 2); fs.writeFileSync(getNcurcPath(), json, { mode: 0o600 /* owner read/write */ @@ -100,9 +105,9 @@ async function auth( const { username, jenkins_token } = getMergedConfig(); if (!username || !jenkins_token) { errorExit( - 'Get your Jenkins API token in https://ci.nodejs.org/me/configure ' + + 'Get your Jenkins API token in https://ci.nodejs.org/me/security ' + 'and run the following command to add it to your ncu config: ' + - 'ncu-config --global set jenkins_token TOKEN' + 'ncu-config --global set -x jenkins_token' ); }; check(username, jenkins_token); @@ -116,7 +121,7 @@ async function auth( 'Get your HackerOne API token in ' + 'https://docs.hackerone.com/organizations/api-tokens.html ' + 'and run the following command to add it to your ncu config: ' + - 'ncu-config --global set h1_token TOKEN or ' + + 'ncu-config --global set -x h1_token or ' + 'ncu-config --global set h1_username USERNAME' ); }; diff --git a/lib/config.js b/lib/config.js index 1b34b5c5..b8ccabb5 100644 --- a/lib/config.js +++ b/lib/config.js @@ -4,6 +4,7 @@ import os from 'node:os'; import { readJson, writeJson } from './file.js'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; +import { forceRunAsync, runSync } from './run.js'; export const GLOBAL_CONFIG = Symbol('globalConfig'); export const PROJECT_CONFIG = Symbol('projectConfig'); @@ -19,12 +20,12 @@ export function getNcurcPath() { } let mergedConfig; -export function getMergedConfig(dir, home) { +export function getMergedConfig(dir, home, additional) { if (mergedConfig == null) { const globalConfig = getConfig(GLOBAL_CONFIG, home); const projectConfig = getConfig(PROJECT_CONFIG, dir); const localConfig = getConfig(LOCAL_CONFIG, dir); - mergedConfig = Object.assign(globalConfig, projectConfig, localConfig); + mergedConfig = Object.assign(globalConfig, projectConfig, localConfig, additional); } return mergedConfig; }; @@ -32,6 +33,50 @@ export function clearCachedConfig() { mergedConfig = null; } +export async function encryptValue(input) { + console.warn('Spawning gpg to encrypt the config value'); + return forceRunAsync( + process.env.GPG_BIN || 'gpg', + ['--default-recipient-self', '--encrypt', '--armor'], + { + captureStdout: true, + ignoreFailure: false, + input + } + ); +} + +function setOwnProperty(target, key, value) { + return Object.defineProperty(target, key, { + __proto__: null, + configurable: true, + enumerable: true, + value + }); +} +function addEncryptedPropertyGetter(target, key, input) { + if (input?.startsWith?.('-----BEGIN PGP MESSAGE-----\n')) { + return Object.defineProperty(target, key, { + __proto__: null, + configurable: true, + get() { + // Using an error object to get a stack trace in debug mode. + const warn = new Error( + `The config value for ${key} is encrypted, spawning gpg to decrypt it...` + ); + console.warn(setOwnProperty(warn, 'name', 'Warning')); + const value = runSync(process.env.GPG_BIN || 'gpg', ['--decrypt'], { input }); + setOwnProperty(target, key, value); + return value; + }, + set(newValue) { + addEncryptedPropertyGetter(target, key, newValue) || + setOwnProperty(target, key, newValue); + } + }); + } +} + export function getConfig(configType, dir) { const configPath = getConfigPath(configType, dir); const encryptedConfigPath = configPath + '.gpg'; @@ -44,7 +89,11 @@ export function getConfig(configType, dir) { } } try { - return readJson(configPath); + const json = readJson(configPath); + for (const [key, val] of Object.entries(json)) { + addEncryptedPropertyGetter(json, key, val); + } + return json; } catch (cause) { throw new Error('Unable to parse config file ' + configPath, { cause }); } diff --git a/lib/session.js b/lib/session.js index 30c66d5e..4c2dacf3 100644 --- a/lib/session.js +++ b/lib/session.js @@ -19,7 +19,7 @@ export default class Session { this.cli = cli; this.dir = dir; this.prid = prid; - this.config = { ...getMergedConfig(this.dir), ...argv }; + this.config = getMergedConfig(this.dir, undefined, argv); this.gpgSign = argv?.['gpg-sign'] ? (argv['gpg-sign'] === true ? ['-S'] : ['-S', argv['gpg-sign']]) : []; @@ -126,7 +126,12 @@ export default class Session { writeJson(this.sessionPath, { state: STARTED, prid: this.prid, - config: this.config + // Filter out getters, those are likely encrypted data we don't need on the session. + config: Object.entries(Object.getOwnPropertyDescriptors(this.config)) + .reduce((pv, [key, desc]) => { + if (Object.hasOwn(desc, 'value')) pv[key] = desc.value; + return pv; + }, { __proto__: null }), }); } diff --git a/test/unit/auth.test.js b/test/unit/auth.test.js index 3bc7d948..e753cb87 100644 --- a/test/unit/auth.test.js +++ b/test/unit/auth.test.js @@ -21,14 +21,16 @@ describe('auth', async function() { it('asks for auth data if no ncurc is found', async function() { await runAuthScript( undefined, - [FIRST_TIME_MSG, MOCKED_TOKEN] + [FIRST_TIME_MSG, MOCKED_TOKEN], + /^Spawning gpg to encrypt the config value\r?\nError: spawn do-not-exist ENOENT(?:.*\n)+Failed encrypt token, storing unencrypted instead\r?\n$/ ); }); it('asks for auth data if ncurc is invalid json', async function() { await runAuthScript( { HOME: 'this is not json' }, - [FIRST_TIME_MSG, MOCKED_TOKEN] + [FIRST_TIME_MSG, MOCKED_TOKEN], + /^Spawning gpg to encrypt the config value\r?\nError: spawn do-not-exist ENOENT(?:.*\n)+Failed encrypt token, storing unencrypted instead\r?\n$/ ); }); @@ -117,7 +119,7 @@ describe('auth', async function() { function runAuthScript( ncurc = {}, expect = [], error = '', fixture = 'run-auth-github') { return new Promise((resolve, reject) => { - const newEnv = { HOME: undefined, XDG_CONFIG_HOME: undefined }; + const newEnv = { HOME: undefined, XDG_CONFIG_HOME: undefined, GPG_BIN: 'do-not-exist' }; if (ncurc.HOME === undefined) ncurc.HOME = ''; // HOME must always be set. for (const envVar in ncurc) { if (ncurc[envVar] === undefined) continue; @@ -154,8 +156,9 @@ function runAuthScript( }); proc.on('close', () => { try { - assert.strictEqual(stderr, error); - assert.strictEqual(expect.length, 0); + if (typeof error === 'string') assert.strictEqual(stderr, error); + else assert.match(stderr, error); + assert.deepStrictEqual(expect, []); if (newEnv.HOME) { fs.rmSync(newEnv.HOME, { recursive: true, force: true }); }