From 8afd57bf457eed8be2b8752190465a52d8f72770 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 9 Nov 2025 13:42:17 +0100 Subject: [PATCH 1/4] feat(ncu-config): add support for partially encrypted config files --- bin/ncu-config.js | 29 +++++++++++++++++++++++++---- lib/config.js | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/bin/ncu-config.js b/bin/ncu-config.js index ffe50209..36d6e4bd 100755 --- a/bin/ncu-config.js +++ b/bin/ncu-config.js @@ -1,5 +1,8 @@ #!/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'; @@ -7,16 +10,22 @@ import { getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG } from '../lib/config.js'; import { setVerbosityFromEnv } from '../lib/verbosity.js'; +import { runSync } from '../lib/run.js'; 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,21 @@ 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 = runSync('gpg', ['--default-recipient-self', '--encrypt', '--armor'], { + input: argv.value + }); + } console.log( `Updating ${configName} configuration ` + `[${argv.key}]: ${config[argv.key]} -> ${argv.value}`); @@ -96,6 +115,8 @@ function listHandler(argv) { } } +const argv = await args.parse(); + if (!['get', 'set', 'list'].includes(argv._[0])) { args.showHelp(); } diff --git a/lib/config.js b/lib/config.js index 1b34b5c5..98cf56d0 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 { runSync } from './run.js'; export const GLOBAL_CONFIG = Symbol('globalConfig'); export const PROJECT_CONFIG = Symbol('projectConfig'); @@ -32,6 +33,33 @@ export function clearCachedConfig() { mergedConfig = null; } +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() { + console.warn(`The config value for ${key} is encrypted, spawning gpg to decrypt it...`); + const value = runSync('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 +72,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 }); } From 41c1ef55f23ba00d459ccaa9c24f9e5afc09d99e Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 9 Nov 2025 18:04:27 +0100 Subject: [PATCH 2/4] fixup! feat(ncu-config): add support for partially encrypted config files --- bin/ncu-config.js | 8 +++----- lib/auth.js | 9 +++++++-- lib/config.js | 15 ++++++++++++--- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/bin/ncu-config.js b/bin/ncu-config.js index 36d6e4bd..67e5545b 100755 --- a/bin/ncu-config.js +++ b/bin/ncu-config.js @@ -7,10 +7,10 @@ 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'; -import { runSync } from '../lib/run.js'; setVerbosityFromEnv(); @@ -91,9 +91,7 @@ async function setHandler(argv) { console.warn('Passing sensitive config value via the shell is discouraged'); } if (argv.encrypt) { - argv.value = runSync('gpg', ['--default-recipient-self', '--encrypt', '--armor'], { - input: argv.value - }); + argv.value = await encryptValue(argv.value); } console.log( `Updating ${configName} configuration ` + diff --git a/lib/auth.js b/lib/auth.js index ffabe13a..da0831e5 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 */ diff --git a/lib/config.js b/lib/config.js index 98cf56d0..b1cb5ec2 100644 --- a/lib/config.js +++ b/lib/config.js @@ -4,7 +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 { runSync } from './run.js'; +import { forceRunAsync, runSync } from './run.js'; export const GLOBAL_CONFIG = Symbol('globalConfig'); export const PROJECT_CONFIG = Symbol('projectConfig'); @@ -33,6 +33,15 @@ 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, + spawnArgs: { input } + }); +} + function setOwnProperty(target, key, value) { return Object.defineProperty(target, key, { __proto__: null, @@ -42,13 +51,13 @@ function setOwnProperty(target, key, value) { }); } function addEncryptedPropertyGetter(target, key, input) { - if (input.startsWith('-----BEGIN PGP MESSAGE-----\n')) { + if (input.startsWith?.('-----BEGIN PGP MESSAGE-----\n')) { return Object.defineProperty(target, key, { __proto__: null, configurable: true, get() { console.warn(`The config value for ${key} is encrypted, spawning gpg to decrypt it...`); - const value = runSync('gpg', ['--decrypt'], { input }); + const value = runSync(process.env.GPG_BIN || 'gpg', ['--decrypt'], { input }); setOwnProperty(target, key, value); return value; }, From 90496c8a72e0db61b7b6e4b07658baf2500c035f Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 9 Nov 2025 18:05:44 +0100 Subject: [PATCH 3/4] fixup! feat(ncu-config): add support for partially encrypted config files --- lib/config.js | 2 +- test/unit/auth.test.js | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/config.js b/lib/config.js index b1cb5ec2..aba5dd03 100644 --- a/lib/config.js +++ b/lib/config.js @@ -51,7 +51,7 @@ function setOwnProperty(target, key, value) { }); } function addEncryptedPropertyGetter(target, key, input) { - if (input.startsWith?.('-----BEGIN PGP MESSAGE-----\n')) { + if (input?.startsWith?.('-----BEGIN PGP MESSAGE-----\n')) { return Object.defineProperty(target, key, { __proto__: null, configurable: true, diff --git a/test/unit/auth.test.js b/test/unit/auth.test.js index 3bc7d948..9c447d10 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 }); } From ea20ccf569e7c316d8cda5e766842e11904f2269 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 9 Nov 2025 18:06:33 +0100 Subject: [PATCH 4/4] fixup! feat(ncu-config): add support for partially encrypted config files --- lib/config.js | 14 +++++++++----- test/unit/auth.test.js | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/config.js b/lib/config.js index aba5dd03..da55930f 100644 --- a/lib/config.js +++ b/lib/config.js @@ -35,11 +35,15 @@ export function clearCachedConfig() { 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, - spawnArgs: { input } - }); + return forceRunAsync( + process.env.GPG_BIN || 'gpg', + ['--default-recipient-self', '--encrypt', '--armor'], + { + captureStdout: true, + ignoreFailure: false, + spawnArgs: { input } + } + ); } function setOwnProperty(target, key, value) { diff --git a/test/unit/auth.test.js b/test/unit/auth.test.js index 9c447d10..e753cb87 100644 --- a/test/unit/auth.test.js +++ b/test/unit/auth.test.js @@ -22,7 +22,7 @@ describe('auth', async function() { await runAuthScript( undefined, [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$/, + /^Spawning gpg to encrypt the config value\r?\nError: spawn do-not-exist ENOENT(?:.*\n)+Failed encrypt token, storing unencrypted instead\r?\n$/ ); }); @@ -30,7 +30,7 @@ describe('auth', async function() { await runAuthScript( { HOME: 'this is not json' }, [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$/, + /^Spawning gpg to encrypt the config value\r?\nError: spawn do-not-exist ENOENT(?:.*\n)+Failed encrypt token, storing unencrypted instead\r?\n$/ ); });