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
29 changes: 24 additions & 5 deletions bin/ncu-config.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -13,10 +17,15 @@ setVerbosityFromEnv();
const args = yargs(hideBin(process.argv))
.completion('completion')
.command({
command: 'set <key> <value>',
command: 'set <key> [<value>]',
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'
Expand Down Expand Up @@ -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 };
Expand All @@ -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}`);
Expand All @@ -96,6 +113,8 @@ function listHandler(argv) {
}
}

const argv = await args.parse();

if (!['get', 'set', 'list'].includes(argv._[0])) {
args.showHelp();
}
15 changes: 10 additions & 5 deletions lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 */
Expand All @@ -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);
Expand All @@ -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'
);
};
Expand Down
55 changes: 52 additions & 3 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -19,19 +20,63 @@ 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;
};
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';
Expand All @@ -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 });
}
Expand Down
9 changes: 7 additions & 2 deletions lib/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']])
: [];
Expand Down Expand Up @@ -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 }),
});
}

Expand Down
13 changes: 8 additions & 5 deletions test/unit/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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$/
);
});

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}
Expand Down
Loading