chore: Remove 'umount' dependency (as advised by "npm audit")

Address security advisory https://www.npmjs.com/advisories/1512

Change-type: patch
This commit is contained in:
Paulo Castro 2021-07-20 22:38:39 +01:00
parent c8f5542c8a
commit f914fa2d8a
16 changed files with 334 additions and 173 deletions

View File

@ -68,7 +68,7 @@ export default class ConfigInjectCmd extends Command {
ConfigInjectCmd,
);
const { safeUmount } = await import('../../utils/helpers');
const { safeUmount } = await import('../../utils/umount');
const drive =
options.drive || (await getVisuals().drive('Select the device/OS drive'));

View File

@ -54,7 +54,7 @@ export default class ConfigReadCmd extends Command {
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigReadCmd);
const { safeUmount } = await import('../../utils/helpers');
const { safeUmount } = await import('../../utils/umount');
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));

View File

@ -58,7 +58,7 @@ export default class ConfigReconfigureCmd extends Command {
public async run() {
const { flags: options } = this.parse<FlagsDef, {}>(ConfigReconfigureCmd);
const { safeUmount } = await import('../../utils/helpers');
const { safeUmount } = await import('../../utils/umount');
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));

View File

@ -75,7 +75,7 @@ export default class ConfigWriteCmd extends Command {
ConfigWriteCmd,
);
const { safeUmount } = await import('../../utils/helpers');
const { safeUmount } = await import('../../utils/umount');
const drive =
options.drive || (await getVisuals().drive('Select the device drive'));

View File

@ -63,7 +63,7 @@ export default class LocalConfigureCmd extends Command {
const path = await import('path');
const reconfix = await import('reconfix');
const denymount = promisify(await import('denymount'));
const { safeUmount } = await import('../../utils/helpers');
const { safeUmount } = await import('../../utils/umount');
const Logger = await import('../../utils/logger');
const logger = Logger.getLogger();

View File

@ -74,9 +74,7 @@ export default class OsInitializeCmd extends Command {
OsInitializeCmd,
);
const { getManifest, safeUmount, sudo } = await import(
'../../utils/helpers'
);
const { getManifest, sudo } = await import('../../utils/helpers');
console.info(`Initializing device ${INIT_WARNING_MESSAGE}`);
@ -96,6 +94,7 @@ export default class OsInitializeCmd extends Command {
`Going to erase ${answers.drive}.`,
true,
);
const { safeUmount } = await import('../../utils/umount');
await safeUmount(answers.drive);
}
@ -108,6 +107,7 @@ export default class OsInitializeCmd extends Command {
]);
if (answers.drive != null) {
const { safeUmount } = await import('../../utils/umount');
await safeUmount(answers.drive);
console.info(`You can safely remove ${answers.drive} now`);
}

View File

@ -136,7 +136,7 @@ export default class SshCmd extends Command {
}
// Remote connection
const { getProxyConfig, which } = await import('../utils/helpers');
const { getProxyConfig } = await import('../utils/helpers');
const { getOnlineTargetDeviceUuid } = await import('../utils/patterns');
const sdk = getBalenaSdk();
@ -156,6 +156,7 @@ export default class SshCmd extends Command {
const deviceId = device.id;
const supervisorVersion = device.supervisor_version;
const { which } = await import('../utils/which');
const [whichProxytunnel, username, proxyUrl] = await Promise.all([
useProxy ? which('proxytunnel', false) : undefined,
@ -301,7 +302,7 @@ export default class SshCmd extends Command {
// container
const childProcess = await import('child_process');
const { escapeRegExp } = await import('lodash');
const { which } = await import('../utils/helpers');
const { which } = await import('../utils/which');
const { deviceContainerEngineBinary } = await import(
'../utils/device/ssh'
);

View File

@ -42,6 +42,7 @@ import {
import type { DeviceInfo } from './device/api';
import { getBalenaSdk, getChalk, stripIndent } from './lazy';
import Logger = require('./logger');
import { exists } from './which';
/**
* Given an array representing the raw `--release-tag` flag of the deploy and
@ -98,15 +99,6 @@ export async function applyReleaseTagKeysAndValues(
);
}
const exists = async (filename: string) => {
try {
await fs.access(filename);
return true;
} catch {
return false;
}
};
const LOG_LENGTH_MAX = 512 * 1024; // 512KB
const compositionFileNames = ['docker-compose.yml', 'docker-compose.yaml'];
const hr =

View File

@ -405,90 +405,6 @@ function windowsCmdExeEscapeArg(arg: string): string {
return `"${arg.replace(/["]/g, '""')}"`;
}
/**
* Error handling wrapper around the npm `which` package:
* "Like the unix which utility. Finds the first instance of a specified
* executable in the PATH environment variable. Does not cache the results,
* so hash -r is not needed when the PATH changes."
*
* @param program Basename of a program, for example 'ssh'
* @param rejectOnMissing If the program cannot be found, reject the promise
* with an ExpectedError instead of fulfilling it with an empty string.
* @returns The program's full path, e.g. 'C:\WINDOWS\System32\OpenSSH\ssh.EXE'
*/
export async function which(
program: string,
rejectOnMissing = true,
): Promise<string> {
const whichMod = await import('which');
let programPath: string;
try {
programPath = await whichMod(program);
} catch (err) {
if (err.code === 'ENOENT') {
if (rejectOnMissing) {
const { ExpectedError } = await import('../errors');
throw new ExpectedError(
`'${program}' program not found. Is it installed?`,
);
} else {
return '';
}
}
throw err;
}
return programPath;
}
/**
* Call which(programName) and spawn() with the given arguments.
*
* If returnExitCodeOrSignal is true, the returned promise will resolve to
* an array [code, signal] with the child process exit code number or exit
* signal string respectively (as provided by the spawn close event).
*
* If returnExitCodeOrSignal is false, the returned promise will reject with
* a custom error if the child process returns a non-zero exit code or a
* non-empty signal string (as reported by the spawn close event).
*
* In either case and if spawn itself emits an error event or fails synchronously,
* the returned promise will reject with a custom error that includes the error
* message of spawn's error.
*/
export async function whichSpawn(
programName: string,
args: string[],
options: import('child_process').SpawnOptions = { stdio: 'inherit' },
returnExitCodeOrSignal = false,
): Promise<[number | undefined, string | undefined]> {
const { spawn } = await import('child_process');
const program = await which(programName);
if (process.env.DEBUG) {
console.error(`[debug] [${program}, ${args.join(', ')}]`);
}
let error: Error | undefined;
let exitCode: number | undefined;
let exitSignal: string | undefined;
try {
[exitCode, exitSignal] = await new Promise((resolve, reject) => {
spawn(program, args, options)
.on('error', reject)
.on('close', (code, signal) => resolve([code, signal]));
});
} catch (err) {
error = err;
}
if (error || (!returnExitCodeOrSignal && (exitCode || exitSignal))) {
const msg = [
`${programName} failed with exit code=${exitCode} signal=${exitSignal}:`,
`[${program}, ${args.join(', ')}]`,
...(error ? [`${error}`] : []),
];
throw new Error(msg.join('\n'));
}
return [exitCode, exitSignal];
}
export interface ProxyConfig {
host: string;
port: string;
@ -614,16 +530,3 @@ export async function awaitInterruptibleTask<
process.removeListener('SIGINT', sigintHandler);
}
}
/** Check if `drive` is mounted, and if so umount it. No-op on Windows. */
export async function safeUmount(drive: string) {
if (!drive) {
return;
}
const { isMounted, umount } = await import('umount');
const isMountedAsync = promisify(isMounted);
if (await isMountedAsync(drive)) {
const umountAsync = promisify(umount);
await umountAsync(drive);
}
}

View File

@ -36,7 +36,7 @@ export async function exec(
cmd: string,
stdout?: NodeJS.WritableStream,
): Promise<void> {
const { which } = await import('./helpers');
const { which } = await import('./which');
const program = await which('ssh');
const args = [
'-n',
@ -132,7 +132,7 @@ export async function spawnSshAndThrowOnError(
args: string[],
options?: import('child_process').SpawnOptions,
) {
const { whichSpawn } = await import('./helpers');
const { whichSpawn } = await import('./which');
const [exitCode, exitSignal] = await whichSpawn(
'ssh',
args,

122
lib/utils/umount.ts Normal file
View File

@ -0,0 +1,122 @@
/**
* @license
* Copyright 2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This module was inspired by the npm `umount` package:
* https://www.npmjs.com/package/umount
* With some important changes:
* - Fix "Command Injection" security advisory 1512
* https://www.npmjs.com/advisories/1512
* - Port from CoffeeScript to TypeScript
* - Convert callbacks to async/await
*/
import { promisify } from 'util';
import * as child_process from 'child_process';
const execFile = promisify(child_process.execFile);
/**
* Unmount a device on Linux or macOS. No-op on Windows.
* @param device Device path, e.g. '/dev/disk2'
*/
export async function umount(device: string): Promise<void> {
if (process.platform === 'win32') {
return;
}
const { sanitizePath, whichBin } = await import('./which');
// sanitize user's input (regular expression attacks ?)
device = sanitizePath(device);
const cmd: string[] = [];
if (process.platform === 'darwin') {
cmd.push('/usr/sbin/diskutil', 'unmountDisk', 'force', device);
} else {
// Linux
const glob = promisify(await import('glob'));
// '?*' expands a base device path like '/dev/sdb' to an array of paths
// like '/dev/sdb1', '/dev/sdb2', ..., '/dev/sdb11', ... (partitions)
// that exist for balenaOS images and are needed as arguments to 'umount'
// on Linux (otherwise, umount produces an error "/dev/sdb: not mounted")
const devices = await glob(`${device}?*`, { nodir: true, nonull: true });
cmd.push(await whichBin('umount'), ...devices);
}
if (cmd.length > 1) {
let stderr = '';
try {
const proc = await execFile(cmd[0], cmd.slice(1));
stderr = proc.stderr;
} catch (err) {
const msg = [
'',
`Error executing "${cmd.join(' ')}"`,
stderr || '',
err.message || '',
];
if (process.platform === 'linux') {
// ignore errors like: "umount: /dev/sdb4: not mounted."
if (process.env.DEBUG) {
console.error(msg.join('\n[debug] '));
}
return;
}
const { ExpectedError } = await import('../errors');
throw new ExpectedError(msg.join('\n'));
}
}
}
/**
* Check if a device is mounted on Linux or macOS. Always true on Windows.
* @param device Device path, e.g. '/dev/disk2'
*/
export async function isMounted(device: string): Promise<boolean> {
if (process.platform === 'win32') {
return true;
}
if (!device) {
return false;
}
const { whichBin } = await import('./which');
const mountCmd = await whichBin('mount');
let stdout = '';
let stderr = '';
try {
const proc = await execFile(mountCmd);
stdout = proc.stdout;
stderr = proc.stderr;
} catch (err) {
const { ExpectedError } = await import('../errors');
throw new ExpectedError(
`Error executing "${mountCmd}":\n${stderr}\n${err.message}`,
);
}
const result = (stdout || '')
.split('\n')
.some((line) => line.startsWith(device));
return result;
}
/** Check if `drive` is mounted and, if so, umount it. No-op on Windows. */
export async function safeUmount(drive: string) {
if (!drive) {
return;
}
if (await isMounted(drive)) {
await umount(drive);
}
}

146
lib/utils/which.ts Normal file
View File

@ -0,0 +1,146 @@
/**
* @license
* Copyright 2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { promises as fs, constants } from 'fs';
import * as path from 'path';
export const { F_OK, R_OK, W_OK, X_OK } = constants;
export async function exists(filename: string, mode = F_OK) {
try {
await fs.access(filename, mode);
return true;
} catch {
return false;
}
}
/**
* Replace sequences of untowardly characters like /[<>:"/\\|?*\u0000-\u001F]/g
* and '.' or '..' with an underscore, plus other rules enforced by the filenamify
* package. See https://github.com/sindresorhus/filenamify/
*/
export function sanitizePath(filepath: string) {
const filenamify = require('filenamify') as typeof import('filenamify');
// normalize also converts forward slash to backslash on Windows
return path
.normalize(filepath)
.split(path.sep)
.map((f) => filenamify(f, { replacement: '_' }))
.join(path.sep);
}
/**
* Given a program name like 'mount', search for it in a pre-defined set of
* folders ('/usr/bin', '/bin', '/usr/sbin', '/sbin') and return the full path if found.
*
* For executables, in some scenarios, this can be more secure than allowing
* any folder in the PATH. Only relevant on Linux or macOS.
*/
export async function whichBin(programName: string): Promise<string> {
for (const dir of ['/usr/bin', '/bin', '/usr/sbin', '/sbin']) {
const candidate = path.join(dir, programName);
if (await exists(candidate, X_OK)) {
return candidate;
}
}
return '';
}
/**
* Error handling wrapper around the npm `which` package:
* "Like the unix which utility. Finds the first instance of a specified
* executable in the PATH environment variable. Does not cache the results,
* so hash -r is not needed when the PATH changes."
*
* @param program Basename of a program, for example 'ssh'
* @param rejectOnMissing If the program cannot be found, reject the promise
* with an ExpectedError instead of fulfilling it with an empty string.
* @returns The program's full path, e.g. 'C:\WINDOWS\System32\OpenSSH\ssh.EXE'
*/
export async function which(
program: string,
rejectOnMissing = true,
): Promise<string> {
const whichMod = await import('which');
let programPath: string;
try {
programPath = await whichMod(program);
} catch (err) {
if (err.code === 'ENOENT') {
if (rejectOnMissing) {
const { ExpectedError } = await import('../errors');
throw new ExpectedError(
`'${program}' program not found. Is it installed?`,
);
} else {
return '';
}
}
throw err;
}
return programPath;
}
/**
* Call which(programName) and spawn() with the given arguments.
*
* If returnExitCodeOrSignal is true, the returned promise will resolve to
* an array [code, signal] with the child process exit code number or exit
* signal string respectively (as provided by the spawn close event).
*
* If returnExitCodeOrSignal is false, the returned promise will reject with
* a custom error if the child process returns a non-zero exit code or a
* non-empty signal string (as reported by the spawn close event).
*
* In either case and if spawn itself emits an error event or fails synchronously,
* the returned promise will reject with a custom error that includes the error
* message of spawn's error.
*/
export async function whichSpawn(
programName: string,
args: string[],
options: import('child_process').SpawnOptions = { stdio: 'inherit' },
returnExitCodeOrSignal = false,
): Promise<[number | undefined, string | undefined]> {
const { spawn } = await import('child_process');
const program = await which(programName);
if (process.env.DEBUG) {
console.error(`[debug] [${program}, ${args.join(', ')}]`);
}
let error: Error | undefined;
let exitCode: number | undefined;
let exitSignal: string | undefined;
try {
[exitCode, exitSignal] = await new Promise((resolve, reject) => {
spawn(program, args, options)
.on('error', reject)
.on('close', (code, signal) => resolve([code, signal]));
});
} catch (err) {
error = err;
}
if (error || (!returnExitCodeOrSignal && (exitCode || exitSignal))) {
const msg = [
`${programName} failed with exit code=${exitCode} signal=${exitSignal}:`,
`[${program}, ${args.join(', ')}]`,
...(error ? [`${error}`] : []),
];
throw new Error(msg.join('\n'));
}
return [exitCode, exitSignal];
}

73
npm-shrinkwrap.json generated
View File

@ -1269,20 +1269,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.16.tgz",
"integrity": "sha512-6CLxw83vQf6DKqXxMPwl8qpF8I7THFZuIwLt4TnNsumxkp1VsRZWT8txQxncT/Rl2UojTsFzWgDG4FRMwafrlA==",
"dev": true
},
"glob": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
"integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
@ -7823,6 +7809,21 @@
"minimatch": "^3.0.4"
}
},
"filename-reserved-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz",
"integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik="
},
"filenamify": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz",
"integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==",
"requires": {
"filename-reserved-regex": "^2.0.0",
"strip-outer": "^1.0.1",
"trim-repeated": "^1.0.0"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@ -8491,9 +8492,9 @@
}
},
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
"integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@ -11972,6 +11973,20 @@
"dev": true,
"optional": true
},
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -17031,6 +17046,14 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
},
"strip-outer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz",
"integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==",
"requires": {
"escape-string-regexp": "^1.0.2"
}
},
"struct-fu": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/struct-fu/-/struct-fu-1.2.1.tgz",
@ -17599,6 +17622,14 @@
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
"integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM="
},
"trim-repeated": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
"integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=",
"requires": {
"escape-string-regexp": "^1.0.2"
}
},
"ts-node": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.0.0.tgz",
@ -17847,14 +17878,6 @@
"integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I=",
"dev": true
},
"umount": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/umount/-/umount-1.1.6.tgz",
"integrity": "sha1-p0kPu9pIunalAKL0vgDV5mAnzQA=",
"requires": {
"lodash": "~4.17.4"
}
},
"unbox-primitive": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",

View File

@ -231,7 +231,9 @@
"fast-boot2": "^1.1.0",
"fast-levenshtein": "^3.0.0",
"file-disk": "^8.0.1",
"filenamify": "^4.3.0",
"get-stdin": "^8.0.0",
"glob": "^7.1.7",
"global-agent": "^2.1.12",
"global-tunnel-ng": "^2.1.1",
"humanize": "0.0.9",
@ -276,7 +278,6 @@
"through2": "^2.0.3",
"tmp": "^0.2.1",
"typed-error": "^3.2.1",
"umount": "^1.1.6",
"update-notifier": "^4.1.0",
"which": "^2.0.2",
"window-size": "^1.1.0"

View File

@ -37,7 +37,7 @@ describe('balena ssh', function () {
if (hasSshExecutable) {
[sshServer, sshServerPort] = await startMockSshServer();
}
const modPath = '../../build/utils/helpers';
const modPath = '../../build/utils/which';
const mod = await import(modPath);
mock(modPath, {
...mod,
@ -130,7 +130,7 @@ describe('balena ssh', function () {
/** Check whether the 'ssh' tool (executable) exists in the PATH */
async function checkSsh(): Promise<boolean> {
const { which } = await import('../../build/utils/helpers');
const { which } = await import('../../build/utils/which');
const sshPath = await which('ssh', false);
if ((sshPath || '').includes('\\Windows\\System32\\OpenSSH\\ssh')) {
// don't use Windows' built-in ssh tool for these test cases

View File

@ -1,27 +0,0 @@
/**
* @license
* Copyright 2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare module 'umount' {
export const umount: (
device: string,
callback: (err?: Error, stdout?: any, stderr?: any) => void,
) => void;
export const isMounted: (
device: string,
callback: (err: Error | null, isMounted?: boolean) => void,
) => void;
}