Skip to content

Commit 7342aff

Browse files
authored
feat(ncu-config): add support for partially encrypted config files (#1004)
1 parent 2589b9c commit 7342aff

File tree

6 files changed

+158
-55
lines changed

6 files changed

+158
-55
lines changed

bin/ncu-config.js

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
#!/usr/bin/env node
22

3+
import * as readline from 'node:readline/promises';
4+
import { stdin as input, stdout as output } from 'node:process';
5+
36
import yargs from 'yargs';
47
import { hideBin } from 'yargs/helpers';
58

69
import {
7-
getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG
10+
getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG,
11+
encryptValue
812
} from '../lib/config.js';
913
import { setVerbosityFromEnv } from '../lib/verbosity.js';
1014

@@ -13,10 +17,15 @@ setVerbosityFromEnv();
1317
const args = yargs(hideBin(process.argv))
1418
.completion('completion')
1519
.command({
16-
command: 'set <key> <value>',
20+
command: 'set <key> [<value>]',
1721
desc: 'Set a config variable',
1822
builder: (yargs) => {
1923
yargs
24+
.option('encrypt', {
25+
describe: 'Store the value encrypted using gpg',
26+
alias: 'x',
27+
type: 'boolean'
28+
})
2029
.positional('key', {
2130
describe: 'key of the configuration',
2231
type: 'string'
@@ -61,8 +70,6 @@ const args = yargs(hideBin(process.argv))
6170
.conflicts('global', 'project')
6271
.help();
6372

64-
const argv = args.parse();
65-
6673
function getConfigType(argv) {
6774
if (argv.global) {
6875
return { configName: 'global', configType: GLOBAL_CONFIG };
@@ -73,9 +80,19 @@ function getConfigType(argv) {
7380
return { configName: 'local', configType: LOCAL_CONFIG };
7481
}
7582

76-
function setHandler(argv) {
83+
async function setHandler(argv) {
7784
const { configName, configType } = getConfigType(argv);
7885
const config = getConfig(configType);
86+
if (!argv.value) {
87+
const rl = readline.createInterface({ input, output });
88+
argv.value = await rl.question('What value do you want to set? ');
89+
rl.close();
90+
} else if (argv.encrypt) {
91+
console.warn('Passing sensitive config value via the shell is discouraged');
92+
}
93+
if (argv.encrypt) {
94+
argv.value = await encryptValue(argv.value);
95+
}
7996
console.log(
8097
`Updating ${configName} configuration ` +
8198
`[${argv.key}]: ${config[argv.key]} -> ${argv.value}`);
@@ -96,6 +113,8 @@ function listHandler(argv) {
96113
}
97114
}
98115

116+
const argv = await args.parse();
117+
99118
if (!['get', 'set', 'list'].includes(argv._[0])) {
100119
args.showHelp();
101120
}

lib/auth.js

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ClientRequest } from 'node:http';
33

44
import ghauth from 'ghauth';
55

6-
import { clearCachedConfig, getMergedConfig, getNcurcPath } from './config.js';
6+
import { clearCachedConfig, encryptValue, getMergedConfig, getNcurcPath } from './config.js';
77

88
export default lazy(auth);
99

@@ -60,68 +60,90 @@ function encode(name, token) {
6060
return Buffer.from(`${name}:${token}`).toString('base64');
6161
}
6262

63+
function setOwnProperty(target, key, value) {
64+
return Object.defineProperty(target, key, {
65+
__proto__: null,
66+
configurable: true,
67+
enumerable: true,
68+
value
69+
});
70+
}
71+
6372
// TODO: support jenkins only...or not necessary?
6473
// TODO: make this a class with dependency (CLI) injectable for testing
6574
async function auth(
6675
options = { github: true },
6776
githubAuth = ghauth) {
68-
const result = {};
77+
const result = {
78+
get github() {
79+
let username;
80+
let token;
81+
try {
82+
({ username, token } = getMergedConfig());
83+
} catch (e) {
84+
// Ignore error and prompt
85+
}
86+
87+
check(username, token);
88+
const github = encode(username, token);
89+
setOwnProperty(result, 'github', github);
90+
return github;
91+
},
92+
93+
get jenkins() {
94+
const { username, jenkins_token } = getMergedConfig();
95+
if (!username || !jenkins_token) {
96+
errorExit(
97+
'Get your Jenkins API token in https://ci.nodejs.org/me/security ' +
98+
'and run the following command to add it to your ncu config: ' +
99+
'ncu-config --global set -x jenkins_token'
100+
);
101+
};
102+
check(username, jenkins_token);
103+
const jenkins = encode(username, jenkins_token);
104+
setOwnProperty(result, 'jenkins', jenkins);
105+
return jenkins;
106+
},
107+
108+
get h1() {
109+
const { h1_username, h1_token } = getMergedConfig();
110+
check(h1_username, h1_token);
111+
const h1 = encode(h1_username, h1_token);
112+
setOwnProperty(result, 'h1', h1);
113+
return h1;
114+
}
115+
};
69116
if (options.github) {
70-
let username;
71-
let token;
117+
let config;
72118
try {
73-
({ username, token } = getMergedConfig());
74-
} catch (e) {
75-
// Ignore error and prompt
119+
config = getMergedConfig();
120+
} catch {
121+
config = {};
76122
}
77-
78-
if (!username || !token) {
123+
if (!Object.hasOwn(config, 'token') || !Object.hasOwn(config, 'username')) {
79124
process.stdout.write(
80125
'If this is your first time running this command, ' +
81126
'follow the instructions to create an access token' +
82127
'. If you prefer to create it yourself on Github, ' +
83128
'see https://github.com/nodejs/node-core-utils/blob/main/README.md.\n');
84129
const credentials = await tryCreateGitHubToken(githubAuth);
85-
username = credentials.user;
86-
token = credentials.token;
130+
const username = credentials.user;
131+
let token;
132+
try {
133+
token = await encryptValue(credentials.token);
134+
} catch (err) {
135+
console.warn('Failed encrypt token, storing unencrypted instead');
136+
token = credentials.token;
137+
}
87138
const json = JSON.stringify({ username, token }, null, 2);
88139
fs.writeFileSync(getNcurcPath(), json, {
89140
mode: 0o600 /* owner read/write */
90141
});
91142
// Try again reading the file
92143
clearCachedConfig();
93-
({ username, token } = getMergedConfig());
94144
}
95-
check(username, token);
96-
result.github = encode(username, token);
97145
}
98146

99-
if (options.jenkins) {
100-
const { username, jenkins_token } = getMergedConfig();
101-
if (!username || !jenkins_token) {
102-
errorExit(
103-
'Get your Jenkins API token in https://ci.nodejs.org/me/configure ' +
104-
'and run the following command to add it to your ncu config: ' +
105-
'ncu-config --global set jenkins_token TOKEN'
106-
);
107-
};
108-
check(username, jenkins_token);
109-
result.jenkins = encode(username, jenkins_token);
110-
}
111-
112-
if (options.h1) {
113-
const { h1_username, h1_token } = getMergedConfig();
114-
if (!h1_username || !h1_token) {
115-
errorExit(
116-
'Get your HackerOne API token in ' +
117-
'https://docs.hackerone.com/organizations/api-tokens.html ' +
118-
'and run the following command to add it to your ncu config: ' +
119-
'ncu-config --global set h1_token TOKEN or ' +
120-
'ncu-config --global set h1_username USERNAME'
121-
);
122-
};
123-
result.h1 = encode(h1_username, h1_token);
124-
}
125147
return result;
126148
}
127149

lib/config.js

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from 'node:os';
44
import { readJson, writeJson } from './file.js';
55
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
66
import { spawnSync } from 'node:child_process';
7+
import { forceRunAsync, runSync } from './run.js';
78

89
export const GLOBAL_CONFIG = Symbol('globalConfig');
910
export const PROJECT_CONFIG = Symbol('projectConfig');
@@ -19,19 +20,63 @@ export function getNcurcPath() {
1920
}
2021

2122
let mergedConfig;
22-
export function getMergedConfig(dir, home) {
23+
export function getMergedConfig(dir, home, additional) {
2324
if (mergedConfig == null) {
2425
const globalConfig = getConfig(GLOBAL_CONFIG, home);
2526
const projectConfig = getConfig(PROJECT_CONFIG, dir);
2627
const localConfig = getConfig(LOCAL_CONFIG, dir);
27-
mergedConfig = Object.assign(globalConfig, projectConfig, localConfig);
28+
mergedConfig = Object.assign(globalConfig, projectConfig, localConfig, additional);
2829
}
2930
return mergedConfig;
3031
};
3132
export function clearCachedConfig() {
3233
mergedConfig = null;
3334
}
3435

36+
export async function encryptValue(input) {
37+
console.warn('Spawning gpg to encrypt the config value');
38+
return forceRunAsync(
39+
process.env.GPG_BIN || 'gpg',
40+
['--default-recipient-self', '--encrypt', '--armor'],
41+
{
42+
captureStdout: true,
43+
ignoreFailure: false,
44+
input
45+
}
46+
);
47+
}
48+
49+
function setOwnProperty(target, key, value) {
50+
return Object.defineProperty(target, key, {
51+
__proto__: null,
52+
configurable: true,
53+
enumerable: true,
54+
value
55+
});
56+
}
57+
function addEncryptedPropertyGetter(target, key, input) {
58+
if (input?.startsWith?.('-----BEGIN PGP MESSAGE-----\n')) {
59+
return Object.defineProperty(target, key, {
60+
__proto__: null,
61+
configurable: true,
62+
get() {
63+
// Using an error object to get a stack trace in debug mode.
64+
const warn = new Error(
65+
`The config value for ${key} is encrypted, spawning gpg to decrypt it...`
66+
);
67+
console.warn(setOwnProperty(warn, 'name', 'Warning'));
68+
const value = runSync(process.env.GPG_BIN || 'gpg', ['--decrypt'], { input });
69+
setOwnProperty(target, key, value);
70+
return value;
71+
},
72+
set(newValue) {
73+
addEncryptedPropertyGetter(target, key, newValue) ||
74+
setOwnProperty(target, key, newValue);
75+
}
76+
});
77+
}
78+
}
79+
3580
export function getConfig(configType, dir) {
3681
const configPath = getConfigPath(configType, dir);
3782
const encryptedConfigPath = configPath + '.gpg';
@@ -44,7 +89,11 @@ export function getConfig(configType, dir) {
4489
}
4590
}
4691
try {
47-
return readJson(configPath);
92+
const json = readJson(configPath);
93+
for (const [key, val] of Object.entries(json)) {
94+
addEncryptedPropertyGetter(json, key, val);
95+
}
96+
return json;
4897
} catch (cause) {
4998
throw new Error('Unable to parse config file ' + configPath, { cause });
5099
}

lib/session.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default class Session {
1919
this.cli = cli;
2020
this.dir = dir;
2121
this.prid = prid;
22-
this.config = { ...getMergedConfig(this.dir), ...argv };
22+
this.config = getMergedConfig(this.dir, undefined, argv);
2323
this.gpgSign = argv?.['gpg-sign']
2424
? (argv['gpg-sign'] === true ? ['-S'] : ['-S', argv['gpg-sign']])
2525
: [];
@@ -126,7 +126,12 @@ export default class Session {
126126
writeJson(this.sessionPath, {
127127
state: STARTED,
128128
prid: this.prid,
129-
config: this.config
129+
// Filter out getters, those are likely encrypted data we don't need on the session.
130+
config: Object.entries(Object.getOwnPropertyDescriptors(this.config))
131+
.reduce((pv, [key, desc]) => {
132+
if (Object.hasOwn(desc, 'value')) pv[key] = desc.value;
133+
return pv;
134+
}, { __proto__: null }),
130135
});
131136
}
132137

test/fixtures/run-auth-github.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ async function mockCredentials(options) {
1616
(async function() {
1717
const { default: auth } = await import('../../lib/auth.js');
1818
const authParams = await auth({ github: true }, mockCredentials);
19+
if (typeof authParams === 'object' && authParams != null) {
20+
for (const key of Object.getOwnPropertyNames(authParams)) {
21+
if (key !== 'github') delete authParams[key];
22+
}
23+
}
1924
process.stdout.write(`${JSON.stringify(authParams)}\n`);
2025
})().catch(err => {
2126
console.error(err);

test/unit/auth.test.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ describe('auth', async function() {
2121
it('asks for auth data if no ncurc is found', async function() {
2222
await runAuthScript(
2323
undefined,
24-
[FIRST_TIME_MSG, MOCKED_TOKEN]
24+
[FIRST_TIME_MSG, MOCKED_TOKEN],
25+
/^Spawning gpg to encrypt the config value\r?\nError: spawn do-not-exist ENOENT(?:.*\n)+Failed encrypt token, storing unencrypted instead\r?\n$/
2526
);
2627
});
2728

2829
it('asks for auth data if ncurc is invalid json', async function() {
2930
await runAuthScript(
3031
{ HOME: 'this is not json' },
31-
[FIRST_TIME_MSG, MOCKED_TOKEN]
32+
[FIRST_TIME_MSG, MOCKED_TOKEN],
33+
/^Spawning gpg to encrypt the config value\r?\nError: spawn do-not-exist ENOENT(?:.*\n)+Failed encrypt token, storing unencrypted instead\r?\n$/
3234
);
3335
});
3436

@@ -117,7 +119,7 @@ describe('auth', async function() {
117119
function runAuthScript(
118120
ncurc = {}, expect = [], error = '', fixture = 'run-auth-github') {
119121
return new Promise((resolve, reject) => {
120-
const newEnv = { HOME: undefined, XDG_CONFIG_HOME: undefined };
122+
const newEnv = { HOME: undefined, XDG_CONFIG_HOME: undefined, GPG_BIN: 'do-not-exist' };
121123
if (ncurc.HOME === undefined) ncurc.HOME = ''; // HOME must always be set.
122124
for (const envVar in ncurc) {
123125
if (ncurc[envVar] === undefined) continue;
@@ -154,8 +156,9 @@ function runAuthScript(
154156
});
155157
proc.on('close', () => {
156158
try {
157-
assert.strictEqual(stderr, error);
158-
assert.strictEqual(expect.length, 0);
159+
if (typeof error === 'string') assert.strictEqual(stderr, error);
160+
else assert.match(stderr, error);
161+
assert.deepStrictEqual(expect, []);
159162
if (newEnv.HOME) {
160163
fs.rmSync(newEnv.HOME, { recursive: true, force: true });
161164
}

0 commit comments

Comments
 (0)