Fix privilege elevation (sudo) for 'os initialize', 'join', 'leave'

* sudo shell arguments required escaping for 'os initialize'
* sudo was not working for standalone zip packages (incorrect
  Node.js path in argv[0])
* Interactive 'join' and 'leave' not working on Windows because
  'windosu' does not capture stderr.

Change-type: patch
Signed-off-by: Paulo Castro <paulo@balena.io>
This commit is contained in:
Paulo Castro 2019-10-14 00:52:43 +01:00
parent 43ae9b672c
commit 05cb89725e
6 changed files with 239 additions and 57 deletions

View File

@ -20,11 +20,10 @@ import chalk from 'chalk';
import _ = require('lodash'); import _ = require('lodash');
import os = require('os'); import os = require('os');
import visuals = require('resin-cli-visuals'); import visuals = require('resin-cli-visuals');
import rindle = require('rindle'); import * as ShellEscape from 'shell-escape';
import { InitializeEmitter, OperationState } from 'balena-device-init'; import { InitializeEmitter, OperationState } from 'balena-device-init';
const waitStreamAsync = Bluebird.promisify(rindle.wait);
const balena = BalenaSdk.fromSharedOptions(); const balena = BalenaSdk.fromSharedOptions();
export function getGroupDefaults(group: { export function getGroupDefaults(group: {
@ -59,19 +58,44 @@ export function stateToString(state: OperationState) {
} }
} }
export function sudo( /**
* Execute a child process with admin / superuser privileges, prompting the user for
* elevation as needed, and taking care of shell-escaping arguments in a suitable way
* for Windows and Linux/Mac.
*
* @param command Unescaped array of command and args to be executed. If isCLIcmd is
* true, the command should not include the 'node' or 'balena' components, for example:
* ['internal', 'osinit', ...]. This function will add argv[0] and argv[1] as needed
* (taking process.pkg into account -- CLI standalone zip package), and will also
* shell-escape the arguments as needed, taking into account the differences between
* bash/sh and the Windows cmd.exe in relation to escape characters.
* @param msg Optional message for the user, before the password prompt
* @param stderr Optional stream to which stderr should be piped
* @param isCLIcmd (default: true) Whether the command array is a balena CLI command
* (e.g. ['internal', 'osinit', ...]), in which case process.argv[0] and argv[1] are
* added as necessary, depending on whether the CLI is running as a standalone zip
* package (with Node built in).
*/
export async function sudo(
command: string[], command: string[],
{ stderr, msg }: { stderr?: NodeJS.WritableStream; msg?: string } = {}, {
stderr,
msg,
isCLIcmd,
}: { stderr?: NodeJS.WritableStream; msg?: string; isCLIcmd?: boolean } = {},
) { ) {
const { executeWithPrivileges } = require('./sudo'); const { executeWithPrivileges } = await import('./sudo');
if (os.platform() !== 'win32') { if (os.platform() !== 'win32') {
console.log( console.log(
msg || 'If asked please type your computer password to continue', msg ||
'Admin privileges required: you may be asked for your computer password to continue.',
); );
} }
if (isCLIcmd == null) {
return executeWithPrivileges(command, stderr); isCLIcmd = true;
}
await executeWithPrivileges(command, stderr, isCLIcmd);
} }
export function runCommand(command: string): Bluebird<void> { export function runCommand(command: string): Bluebird<void> {
@ -106,7 +130,7 @@ export async function getOsVersion(
return init.getImageOsVersion(image, manifest); return init.getImageOsVersion(image, manifest);
} }
export function osProgressHandler(step: InitializeEmitter) { export async function osProgressHandler(step: InitializeEmitter) {
step.on('stdout', process.stdout.write.bind(process.stdout)); step.on('stdout', process.stdout.write.bind(process.stdout));
step.on('stderr', process.stderr.write.bind(process.stderr)); step.on('stderr', process.stderr.write.bind(process.stderr));
@ -124,7 +148,10 @@ export function osProgressHandler(step: InitializeEmitter) {
step.on('burn', state => progressBars[state.type].update(state)); step.on('burn', state => progressBars[state.type].update(state));
return waitStreamAsync(step); await new Promise((resolve, reject) => {
step.on('error', reject);
step.on('end', resolve);
});
} }
export function getArchAndDeviceType( export function getArchAndDeviceType(
@ -272,3 +299,50 @@ export function getManualSortCompareFunction<T, U = T>(
} }
}; };
} }
/**
* Shell argument escaping compatible with sh, bash and Windows cmd.exe.
* @param arg Arguments to be escaped
* @param detectShell Whether to use the SHELL and ComSpec environment
* variables to determine the shell type (sh / bash / cmd.exe). This may be
* useful to detect MSYS / MSYS2, which use bash on Windows. However, if the
* purpose is to use child_process.spawn(..., {shell: true}) and related
* functions, set this to false because child_process.spawn() always uses
* env.ComSpec (cmd.exe) on Windows, even when running on MSYS / MSYS2.
*/
export function shellEscape(args: string[], detectShell = false): string[] {
let isWindowsCmdExeShell: boolean;
if (detectShell) {
isWindowsCmdExeShell =
// neither bash nor sh (e.g. not MSYS, MSYS2, WSL)
process.env.SHELL == null &&
// Windows cmd.exe or PowerShell
process.env.ComSpec != null &&
process.env.ComSpec.endsWith('cmd.exe');
} else {
isWindowsCmdExeShell = process.platform === 'win32';
}
if (isWindowsCmdExeShell) {
return args.map(v => windowsCmdExeEscapeArg(v));
} else {
const shellEscapeFunc: typeof ShellEscape = require('shell-escape');
return args.map(v => shellEscapeFunc([v]));
}
}
/**
* Escape a string argument to be passed through the Windows cmd.exe shell.
* cmd.exe escaping has some peculiarities, like using the caret character
* instead of a backslash for reserved / metacharacters. Reference:
* https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
*/
function windowsCmdExeEscapeArg(arg: string): string {
// if it is already double quoted, remove the double quotes
if (arg.length > 1 && arg.startsWith('"') && arg.endsWith('"')) {
arg = arg.slice(1, -1);
}
// escape cmd.exe metacharacters with the '^' (caret) character
arg = arg.replace(/[()%!^<>&|]/g, '^$&');
// duplicate internal double quotes, and double quote overall
return `"${arg.replace(/["]/g, '""')}"`;
}

View File

@ -174,7 +174,7 @@ async function getOrSelectLocalDevice(deviceIp?: string): Promise<string> {
stream.pipe(process.stderr); stream.pipe(process.stderr);
const { sudo } = await import('../utils/helpers'); const { sudo } = await import('../utils/helpers');
const command = process.argv.slice(0, 2).concat(['internal', 'scandevices']); const command = ['internal', 'scandevices'];
await sudo(command, { await sudo(command, {
stderr: stream, stderr: stream,
msg: msg:

View File

@ -14,34 +14,107 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import { spawn, StdioOptions } from 'child_process';
import * as Bluebird from 'bluebird'; import { ChildProcess, spawn, SpawnOptions } from 'child_process';
import * as rindle from 'rindle'; import { stripIndent } from 'common-tags';
import * as _ from 'lodash';
/**
* Execute a child process with admin / superuser privileges, prompting the user for
* elevation as needed, and taking care of shell-escaping arguments in a suitable way
* for Windows and Linux/Mac.
*
* @param command Unescaped array of command and args to be executed. If isCLIcmd is
* true, the command should not include the 'node' or 'balena' components, for
* example: ['internal', 'osinit', ...]. This function will add argv[0] and argv[1]
* as needed (taking process.pkg into account -- CLI standalone zip package), and
* will also shell-escape the arguments as needed, taking into account the
* differences between bash/sh and the Windows cmd.exe in relation to escape
* characters.
* @param stderr Optional stream to which stderr should be piped
* @param isCLIcmd (default: true) Whether the command array is a balena CLI command
* (e.g. ['internal', 'osinit', ...]), in which case process.argv[0] and argv[1] are
* added as necessary, depending on whether the CLI is running as a standalone zip
* package (with Node built in).
*/
export async function executeWithPrivileges( export async function executeWithPrivileges(
command: string[], command: string[],
stderr?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream,
): Promise<string> { isCLIcmd = true,
const stdio: StdioOptions = [ ): Promise<void> {
'inherit', // whether the CLI is already running with admin / super user privileges
'inherit', const isElevated = await (await import('is-elevated'))();
stderr ? 'pipe' : 'inherit', const { shellEscape } = await import('./helpers');
]; const opts: SpawnOptions = {
const opts = {
env: process.env, env: process.env,
stdio, stdio: ['inherit', 'inherit', stderr ? 'pipe' : 'inherit'],
}; };
if (isElevated) {
if (isCLIcmd) {
// opts.shell is false, so preserve pkg's '/snapshot' at argv[1]
command = [process.argv[0], process.argv[1], ...command];
}
// already running with privileges: simply spawn the command
await spawnAndPipe(command[0], command.slice(1), opts, stderr);
} else {
if (isCLIcmd) {
// In the case of a CLI standalone zip package (process.pkg is truthy),
// the Node executable is bundled with the source code and node_modules
// folder in a single file named in argv[0]. In this case, argv[1]
// contains a "/snapshot" path that should be discarded when opts.shell
// is true.
command = (process as any).pkg
? [process.argv[0], ...command]
: [process.argv[0], process.argv[1], ...command];
}
opts.shell = true;
const escapedCmd = shellEscape(command);
// running as ordinary user: elevate privileges
if (process.platform === 'win32') {
await windosuExec(escapedCmd, stderr);
} else {
await spawnAndPipe('sudo', escapedCmd, opts, stderr);
}
}
}
const args = process.argv async function spawnAndPipe(
.slice(0, 2) spawnCmd: string,
.concat(['internal', 'sudo', command.join(' ')]); spawnArgs: string[],
spawnOpts: SpawnOptions,
const ps = spawn(args[0], args.slice(1), opts); stderr?: NodeJS.WritableStream,
) {
await new Promise((resolve, reject) => {
const ps: ChildProcess = spawn(spawnCmd, spawnArgs, spawnOpts);
ps.on('error', reject);
ps.on('exit', codeOrSignal => {
if (codeOrSignal !== 0) {
const errMsgCmd = `[${[spawnCmd, ...spawnArgs].join()}]`;
reject(
new Error(
`Child process exited with error code "${codeOrSignal}" for command:\n${errMsgCmd}`,
),
);
} else {
resolve();
}
});
if (stderr) { if (stderr) {
ps.stderr.pipe(stderr); ps.stderr.pipe(stderr);
} }
});
return Bluebird.fromCallback<string>(callback => rindle.wait(ps, callback)); }
async function windosuExec(
escapedArgs: string[],
stderr?: NodeJS.WritableStream,
): Promise<void> {
if (stderr) {
const msg = stripIndent`
Error: unable to elevate privileges. Please run the command prompt as an Administrator:
https://www.howtogeek.com/194041/how-to-open-the-command-prompt-as-administrator-in-windows-8.1/
`;
throw new Error(msg);
}
return require('windosu').exec(escapedArgs.join(' '));
} }

68
npm-shrinkwrap.json generated
View File

@ -2704,6 +2704,20 @@
"version": "1.5.2", "version": "1.5.2",
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=" "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo="
},
"is-elevated": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-1.0.0.tgz",
"integrity": "sha1-+IThcowajY1ez2I/iHM8bm7h4u4=",
"requires": {
"is-admin": "^1.0.2",
"is-root": "^1.0.0"
}
},
"is-root": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-root/-/is-root-1.0.0.tgz",
"integrity": "sha1-B7bCM7w5TNnQK6FclmvWZg1jQtU="
} }
} }
}, },
@ -7599,12 +7613,22 @@
"integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=" "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE="
}, },
"is-elevated": { "is-elevated": {
"version": "1.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-elevated/-/is-elevated-3.0.0.tgz",
"integrity": "sha1-+IThcowajY1ez2I/iHM8bm7h4u4=", "integrity": "sha512-wjcp6RkouU9jpg55zERl+BglvV5j4jx5c/EMvQ+d12j/+nIEenNWPu+qc0tCg3JkLodbKZMg1qhJzEwG4qjclg==",
"requires": { "requires": {
"is-admin": "^1.0.2", "is-admin": "^3.0.0",
"is-root": "^1.0.0" "is-root": "^2.1.0"
},
"dependencies": {
"is-admin": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-admin/-/is-admin-3.0.0.tgz",
"integrity": "sha512-wOa3CXFJAu8BZ2BDtG9xYOOrsq6oiSvc2jFPy4X/HINx5bmJUcW8e+apItVbU2E7GIfBVaFVO7Zit4oAWtTJcw==",
"requires": {
"execa": "^1.0.0"
}
}
} }
}, },
"is-extendable": { "is-extendable": {
@ -7741,9 +7765,9 @@
"integrity": "sha1-EaBgVotnM5REAz0BJaYaINVk+zQ=" "integrity": "sha1-EaBgVotnM5REAz0BJaYaINVk+zQ="
}, },
"is-root": { "is-root": {
"version": "1.0.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-root/-/is-root-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz",
"integrity": "sha1-B7bCM7w5TNnQK6FclmvWZg1jQtU=" "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="
}, },
"is-scoped": { "is-scoped": {
"version": "1.0.0", "version": "1.0.0",
@ -14993,15 +15017,15 @@
} }
}, },
"resin-cli-visuals": { "resin-cli-visuals": {
"version": "1.4.3", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/resin-cli-visuals/-/resin-cli-visuals-1.4.3.tgz", "resolved": "https://registry.npmjs.org/resin-cli-visuals/-/resin-cli-visuals-1.4.4.tgz",
"integrity": "sha512-vzj6iTdWd0zq1pRXNJZmgMPL6cKS66lLOZlT4lr2kam/ohWFRQNZfmwigZRbrtUOOtBwdVujqSqYAP9USjX9NQ==", "integrity": "sha512-EchgufKC2loXoxB4xjnFtR2bJZbR5xNtJ/pVLc8G2But45TwgkORC5RDbWNe6fCdXJvgWFW1RgZ0Z0tc7Zd+Nw==",
"requires": { "requires": {
"bluebird": "^3.5.1", "bluebird": "^3.5.1",
"chalk": "^2.3.1", "chalk": "^2.3.1",
"cli-spinner": "0.2.7", "cli-spinner": "0.2.7",
"columnify": "^1.5.1", "columnify": "^1.5.1",
"drivelist": "^8.0.6", "drivelist": "^8.0.7",
"inquirer": "^0.11.0", "inquirer": "^0.11.0",
"inquirer-dynamic-list": "^1.0.0", "inquirer-dynamic-list": "^1.0.0",
"is-promise": "^2.1.0", "is-promise": "^2.1.0",
@ -15041,14 +15065,14 @@
} }
}, },
"drivelist": { "drivelist": {
"version": "8.0.6", "version": "8.0.7",
"resolved": "https://registry.npmjs.org/drivelist/-/drivelist-8.0.6.tgz", "resolved": "https://registry.npmjs.org/drivelist/-/drivelist-8.0.7.tgz",
"integrity": "sha512-KkXaGslqZP7Y13rofyFZBUl9dQrs0wqT5aXq5PAr67uPE2IDXEUYH+LNGTv69xpd7cUImjQvvB9H4bkziLIs1w==", "integrity": "sha512-KgFVzXux+rdRjzt1b1Vv4btqCmB/XC0wOfodtUev9MkJcL8VyxjTsopt+lE3GlSv4M+XPgS9dqf9fBowJ8I/2w==",
"requires": { "requires": {
"bindings": "^1.3.0", "bindings": "^1.3.0",
"debug": "^3.1.0", "debug": "^3.1.0",
"mz": "^2.7.0", "mz": "^2.7.0",
"nan": "^2.10.0", "nan": "^2.14.0",
"prebuild-install": "^5.2.4" "prebuild-install": "^5.2.4"
} }
}, },
@ -15099,6 +15123,11 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=" "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y="
}, },
"nan": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
"integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
},
"run-async": { "run-async": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz",
@ -17839,13 +17868,14 @@
} }
}, },
"windosu": { "windosu": {
"version": "0.2.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/windosu/-/windosu-0.2.0.tgz", "resolved": "https://registry.npmjs.org/windosu/-/windosu-0.3.0.tgz",
"integrity": "sha1-oqGqymWHczDqCxtoXvPACQIKY4k=", "integrity": "sha1-Bhx/5KgRikrZ9P420/kA0ivApHk=",
"optional": true, "optional": true,
"requires": { "requires": {
"cli-width": "^1.1.0", "cli-width": "^1.1.0",
"q": "~0.9.7", "q": "~0.9.7",
"rimraf": "^2.5.2",
"tail": "~0.3.1", "tail": "~0.3.1",
"temp": "~0.6.0" "temp": "~0.6.0"
}, },

View File

@ -33,7 +33,9 @@
"build/actions-oclif", "build/actions-oclif",
"build/auth/pages/*.ejs", "build/auth/pages/*.ejs",
"build/hooks", "build/hooks",
"node_modules/resin-discoverable-services/services/**/*" "node_modules/resin-discoverable-services/services/**/*",
"node_modules/windosu/*.bat",
"node_modules/windosu/*.cmd"
] ]
}, },
"scripts": { "scripts": {
@ -135,7 +137,6 @@
"publish-release": "^1.6.0", "publish-release": "^1.6.0",
"resin-lint": "^3.0.1", "resin-lint": "^3.0.1",
"rewire": "^3.0.2", "rewire": "^3.0.2",
"shell-escape": "^0.2.0",
"sinon": "^7.4.1", "sinon": "^7.4.1",
"ts-node": "^8.1.0", "ts-node": "^8.1.0",
"typescript": "^3.4.5" "typescript": "^3.4.5"
@ -184,7 +185,8 @@
"humanize": "0.0.9", "humanize": "0.0.9",
"ignore": "^5.1.1", "ignore": "^5.1.1",
"inquirer": "^3.1.1", "inquirer": "^3.1.1",
"is-root": "^1.0.0", "is-elevated": "^3.0.0",
"is-root": "^2.1.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"klaw": "^3.0.0", "klaw": "^3.0.0",
"livepush": "^2.0.1", "livepush": "^2.0.1",
@ -205,7 +207,7 @@
"reconfix": "^0.1.0", "reconfix": "^0.1.0",
"request": "^2.81.0", "request": "^2.81.0",
"resin-cli-form": "^2.0.1", "resin-cli-form": "^2.0.1",
"resin-cli-visuals": "^1.4.0", "resin-cli-visuals": "^1.4.4",
"resin-compose-parse": "^2.1.0", "resin-compose-parse": "^2.1.0",
"resin-doodles": "0.0.1", "resin-doodles": "0.0.1",
"resin-image-fs": "^5.0.8", "resin-image-fs": "^5.0.8",
@ -216,6 +218,7 @@
"rimraf": "^2.4.3", "rimraf": "^2.4.3",
"rindle": "^1.3.4", "rindle": "^1.3.4",
"semver": "^5.7.0", "semver": "^5.7.0",
"shell-escape": "^0.2.0",
"split": "^1.0.1", "split": "^1.0.1",
"string-width": "^2.1.1", "string-width": "^2.1.1",
"strip-ansi-stream": "^1.0.0", "strip-ansi-stream": "^1.0.0",
@ -231,6 +234,6 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"net-keepalive": "^1.2.1", "net-keepalive": "^1.2.1",
"windosu": "^0.2.0" "windosu": "^0.3.0"
} }
} }

View File

@ -75,6 +75,8 @@ declare module 'balena-device-init' {
on(event: 'stdout' | 'stderr', callback: (msg: string) => void): void; on(event: 'stdout' | 'stderr', callback: (msg: string) => void): void;
on(event: 'state', callback: (state: OperationState) => void): void; on(event: 'state', callback: (state: OperationState) => void): void;
on(event: 'burn', callback: (state: BurnProgress) => void): void; on(event: 'burn', callback: (state: BurnProgress) => void): void;
on(event: 'end', callback: () => void): void;
on(event: 'error', callback: (error: Error) => void): void;
} }
export function configure( export function configure(