From 1f5486cb0675483d9990c6eb4f75fff39c25939a Mon Sep 17 00:00:00 2001 From: Bernhard Pottler Date: Wed, 27 Nov 2024 20:02:10 +0100 Subject: [PATCH 1/2] fix: issue #384 caused by ps-tree --- package-lock.json | 138 +-------------------- package.json | 1 - src/index.js | 2 +- src/ps-tree2.js | 297 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 302 insertions(+), 136 deletions(-) create mode 100644 src/ps-tree2.js diff --git a/package-lock.json b/package-lock.json index f42d254..4fa3078 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "debug": "4.3.7", "execa": "5.1.1", "lazy-ass": "1.6.0", - "ps-tree": "1.2.0", "wait-on": "8.0.1" }, "bin": { @@ -5514,11 +5513,6 @@ "node": ">=4" } }, - "node_modules/duplexer": { - "version": "0.1.1", - "resolved": "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", - "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=" - }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -6517,20 +6511,6 @@ "node": ">= 0.6" } }, - "node_modules/event-stream": { - "version": "3.3.4", - "resolved": "http://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", - "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=", - "dependencies": { - "duplexer": "~0.1.1", - "from": "~0", - "map-stream": "~0.1.0", - "pause-stream": "0.0.11", - "split": "0.3", - "stream-combiner": "~0.0.4", - "through": "~2.3.1" - } - }, "node_modules/eventemitter3": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", @@ -7447,11 +7427,6 @@ "node": ">= 0.6" } }, - "node_modules/from": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", - "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=" - }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -10462,11 +10437,6 @@ "node": ">=8" } }, - "node_modules/map-stream": { - "version": "0.1.0", - "resolved": "http://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", - "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=" - }, "node_modules/map-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", @@ -17196,14 +17166,6 @@ "node": "*" } }, - "node_modules/pause-stream": { - "version": "0.0.11", - "resolved": "http://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", - "integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=", - "dependencies": { - "through": "~2.3" - } - }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -18491,20 +18453,6 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, - "node_modules/ps-tree": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", - "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", - "dependencies": { - "event-stream": "=3.3.4" - }, - "bin": { - "ps-tree": "bin/ps-tree.js" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -20656,17 +20604,6 @@ "spdx-ranges": "^2.0.0" } }, - "node_modules/split": { - "version": "0.3.3", - "resolved": "http://registry.npmjs.org/split/-/split-0.3.3.tgz", - "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=", - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, "node_modules/split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -20980,14 +20917,6 @@ "graceful-fs": "^4.1.3" } }, - "node_modules/stream-combiner": { - "version": "0.0.4", - "resolved": "http://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", - "integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=", - "dependencies": { - "duplexer": "~0.1.1" - } - }, "node_modules/stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -21387,7 +21316,8 @@ "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true }, "node_modules/through2": { "version": "3.0.2", @@ -27476,11 +27406,6 @@ "is-obj": "^1.0.0" } }, - "duplexer": { - "version": "0.1.1", - "resolved": "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", - "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=" - }, "duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -28266,20 +28191,6 @@ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true }, - "event-stream": { - "version": "3.3.4", - "resolved": "http://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", - "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=", - "requires": { - "duplexer": "~0.1.1", - "from": "~0", - "map-stream": "~0.1.0", - "pause-stream": "0.0.11", - "split": "0.3", - "stream-combiner": "~0.0.4", - "through": "~2.3.1" - } - }, "eventemitter3": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", @@ -29005,11 +28916,6 @@ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "dev": true }, - "from": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", - "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4=" - }, "from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -31445,11 +31351,6 @@ "integrity": "sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==", "dev": true }, - "map-stream": { - "version": "0.1.0", - "resolved": "http://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", - "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ=" - }, "map-visit": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", @@ -36689,14 +36590,6 @@ "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", "dev": true }, - "pause-stream": { - "version": "0.0.11", - "resolved": "http://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", - "integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=", - "requires": { - "through": "~2.3" - } - }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -37743,14 +37636,6 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, - "ps-tree": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", - "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", - "requires": { - "event-stream": "=3.3.4" - } - }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -39513,14 +39398,6 @@ "spdx-ranges": "^2.0.0" } }, - "split": { - "version": "0.3.3", - "resolved": "http://registry.npmjs.org/split/-/split-0.3.3.tgz", - "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=", - "requires": { - "through": "2" - } - }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -39776,14 +39653,6 @@ "graceful-fs": "^4.1.3" } }, - "stream-combiner": { - "version": "0.0.4", - "resolved": "http://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", - "integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=", - "requires": { - "duplexer": "~0.1.1" - } - }, "stream-combiner2": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz", @@ -40094,7 +39963,8 @@ "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true }, "through2": { "version": "3.0.2", diff --git a/package.json b/package.json index 62c6b85..dcc0f6d 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,6 @@ "debug": "4.3.7", "execa": "5.1.1", "lazy-ass": "1.6.0", - "ps-tree": "1.2.0", "wait-on": "8.0.1" }, "release": { diff --git a/src/index.js b/src/index.js index 9c2c44c..16ec2ad 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,7 @@ const is = require('check-more-types') const execa = require('execa') const waitOn = require('wait-on') const Promise = require('bluebird') -const psTree = require('ps-tree') +const { psTree2: psTree} = require('./ps-tree2.js') const debug = require('debug')('start-server-and-test') /** diff --git a/src/ps-tree2.js b/src/ps-tree2.js new file mode 100644 index 0000000..b164a83 --- /dev/null +++ b/src/ps-tree2.js @@ -0,0 +1,297 @@ +const { spawn } = require('node:child_process'); +const { pipeline, Transform, Writable } = require('node:stream'); +const split2 = require('split2'); + +/** + * Create a stream reader to get the list of currently running processes. + * This function runs on windows (using powershell 5.1) and on *nix systems. + * @returns stream.Readable emitting the list of all running processes + */ +function createPsReader() { + let processLister; + if (process.platform === 'win32') { + processLister = spawn('powershell.exe',['Get-WmiObject -Class Win32_Process | Select-Object -Property Name,ProcessId,ParentProcessId,Status | Format-Table']); + } else { + processLister = spawn('ps', ['-A', '-o', 'ppid,pid,stat,comm']); + } + + processLister.stdout.setEncoding('utf8'); + return processLister.stdout; +} + +/** + * Convert incoming lines to an array of objects containing information + * about the selected main process and all of its child processes. + * Incoming chunks are expected to be lines of strings. + * Outgoing chunks are also lines of strings. + * Each chunk must be a single line of data. + * Properties of the options object: + * @param {number|string} pid - pid of the root process to inspect; defaults to + * the root process of the os + * @param {number} maxIterations - maximum number of levels inspected; defaults + * to 10 + * Structure of the resulting array: + * @param {string} PPID - process id of the parent process + * @param {string} PID - process id of the process + * @param {string} COMMAND - name of the process + * @param {string} STAT - status of the process + */ +class LinesToPsObjectsArrayTransform extends Transform { + /** + * Create a transform stream to extract information about a process and its + * child processes as an array of objects from the incoming data (lines of + * strings). + * @param {Object} options - options of the selection with properties: + * pid - pid of the root process to inspect; defaults to the + * root process of the os + * maxIterations - maximum number of levels inspected; + * defaults to 10 + */ + constructor(options) { + super({ readableObjectMode:true }); + const defaultMaxIterations = 10; + + let defaultPpid = (process.platform === 'win32') ? 0 : 1; + this.parentProcessID = options?.['pid'] ?? defaultPpid; + if (typeof this.parentProcessID === 'number') { + this.parentProcessID = this.parentProcessID.toString(); + } + + this.maxIterations = options?.['maxIterations'] ?? defaultMaxIterations; + + this.headers = null; + this.psObjects = []; + } + + /** + * Filter unnecessary lines from the input stream - remove empty lines and the line + * that shows the dashes under the column titles. + * @param {Node.Buffer} chunk - The data to be transformed. + * @param {string} encoding - If the chunk is a string, then this is the encoding type. + * If chunk is a buffer, then this is the special value 'buffer'; + * ignore it in that case. + * @param {Function} callback - A callback function (optionally with an error argument and data) + * to be called after the supplied chunk has been processed. + */ + _transform(chunk, encoding, callback) { + let line = Buffer.isBuffer(chunk) ? chunk.toString(): chunk; + line = line.trim(); + + // Remove unnecessary lines created by powershell + if ((line.length === 0) || line.includes('----')) { + return callback(); + } + + /** + * First line contains the column headers. All columns have a fixed width. + * On windows COMMANDs may contain white spaces; therefore we cannot simply + * split the lines using white spaces as separators. + */ + if (this.headers === null) { + this.headers = this._getColumnDefs(line); + + this.headers = this.headers.map(header => { + header.header = this._normalizeHeader(header.header); + return header; + }); + return callback(); + } + + // Convert string lines to array of objects with process information + const row = {}; + for (const headerDef of this.headers) { + const columnValue = line.substring(headerDef.start, headerDef.end).trim(); + row[headerDef.header] = columnValue; + } + this.psObjects.push(row); + + callback(); + } + + /** + * Select the list of child processes and emit it to the output stream. + * @param {Function} callback - A callback function (optionally with an error + * argument and data) to be called when remaining data has + * been flushed. + */ + _flush(callback) { + // Select objects of main process and its child processes in an array + const childProcesses = this._selectChildProcesses(this.psObjects, this.parentProcessID, this.maxIterations); + this.push(childProcesses); + + callback(); + } + + /** + * Extract the column definitions from the first line. + * On Linux: the first column header is right aligned. + * On Windows: the first column header is left aligned. + * @param {string} line - Header of the list columns + * @returns {Object[]} Array of objects containing the definition of 1 column + */ + _getColumnDefs(line) { + const columnDefinitions = []; + let startOfColumnIncl = 0; + let endOfColumnExcl = 0; + let foundStartOfHeader = false; + let foundEndOfHeader = false; + for (let i = 0; i < line.length; i++) { + const isWhitespace = line.substring(i, i + 1).trim() === ''; + if (!foundStartOfHeader && !isWhitespace) { + // search for first header, if it is right aligned (on linux) + foundStartOfHeader = true; + } else if (foundStartOfHeader && isWhitespace) { + // search for end of header text + foundEndOfHeader = true; + } else if (foundEndOfHeader && !isWhitespace) { + endOfColumnExcl = i - 1; + const header = line.substring(startOfColumnIncl, endOfColumnExcl).trim(); + columnDefinitions.push({start:startOfColumnIncl, end:endOfColumnExcl, header:header}); + startOfColumnIncl = i; + foundStartOfHeader = true; + foundEndOfHeader = false; + } + } + + // last column + const header = line.substring(startOfColumnIncl, line.length).trim(); + columnDefinitions.push({start:startOfColumnIncl, end:line.length, header:header}); + return columnDefinitions; + } + + /** + * Normalizes the given header from the Windows title to the Linux title + * of the processes list. + * @param {string} header - Header string to normalize + */ + _normalizeHeader(header) { + switch (header) { + case 'Name': // for windows + case 'COMM': // for linux + return 'COMMAND'; + case 'ParentProcessId': + return 'PPID'; + case 'ProcessId': + return 'PID'; + case 'Status': + return 'STAT'; + default: + return header + } + } + + /** + * Get the list of the main process with the given pid and of all its child + * processes down to 'maxIterations' levels. + * @param {Object[]} allProcesses - array of objects for all processes + * @param {string} pid - process id of the parent + * @param {number} maxIterations - maximum number of levels inspected + * @returns array containing objects describing all found processes + */ + _selectChildProcesses(allProcesses, pid, maxIterations) { + let remainingIterations = maxIterations; + const childProcesses = []; + const childProcessesIds = []; + let parents = [ pid ]; + let nextParents = []; + while ((remainingIterations > 0) && (parents.length > 0)) { + for (const psObject of allProcesses) { + if (parents.includes(psObject['PPID']) && !childProcessesIds.includes(psObject['PID'])) { + childProcesses.push(psObject); + childProcessesIds.push(psObject['PID']); + nextParents.push(psObject['PID']); + } + } + + parents = nextParents; + nextParents = []; + remainingIterations--; + } + + return childProcesses; + } +} + +/** + * Emit incoming array of process information to a given callback. + * The callback is given as a function in the options object. + * Properties of the options object: + * @param {Function} resultCallback - callback with selected processes list as + * parameter + * Structure of the resulting array: + * @param {string} PPID - process id of the parent process + * @param {string} PID - process id of the process + * @param {string} COMMAND - name of the process + * @param {string} STAT - status of the process + */ +class ObjectToCallbackWritable extends Writable { + /** + * Create a writable stream to emit the array of information about a process + * and its child processes by calling a given callback function. + * @param {Object} options - options of the selection with properties: + * resultCallback - callback with selected processes list + */ + constructor(options) { + super({ objectMode:true }); + + this.resultCallback = options?.['resultCallback']; + this.childProcesses = []; + } + + /** + * Collect the incoming processes list into an internal array. + * @param {Node.Buffer} chunk - The data to be collected (array or single object). + * @param {string} encoding - If the chunk is a string, then this is the encoding type. + * If chunk is a buffer, then this is the special value 'buffer'; + * ignore it in that case. + * @param {Function} callback - A callback function (optionally with an error argument and data) + * to be called after the supplied chunk has been processed. + */ + _write(chunk, encode, callback) { + if(Array.isArray(chunk)) { + this.childProcesses.push(...chunk); + } else { + this.childProcesses.push(chunk); + } + + callback(); + } + + /** + * Emit the collected list of child processes with the callback given in options. + * + * @param {Function} callback - A callback function (optionally with an error + * argument and data) to be called when remaining data has + * been flushed. + */ + _final(callback) { + if ((this.resultCallback !== undefined) && (typeof this.resultCallback === 'function')) { + this.resultCallback(this.childProcesses); + } + + callback(); + } +} + +/** + * Get all processes of a child process with a given id. + * This is a replacement for the 'indexzero/ps-tree' package without the + * vulnerabilities. + * @param {number | string | undefined} pid - ID of the process under inspection + * @param {nodeCallback} callback - function(err, children) to return errors/children + */ +function psTree2(pid, callback) { + pipeline( + createPsReader(), + split2({ maxLength: 200}), + new LinesToPsObjectsArrayTransform({pid: pid, maxIterations: 5}), + new ObjectToCallbackWritable({ resultCallback: (children) => callback(null, children) }), + (err) => { + if (err) { + callback(err); + } + } + ); +} + +exports.psTree2 = psTree2; From 9d6fd6cf1e129506d28bd8a10a995ebdf36f1a3e Mon Sep 17 00:00:00 2001 From: Bernhard Pottler Date: Thu, 28 Nov 2024 10:33:46 +0100 Subject: [PATCH 2/2] fix: lines in *nix have a varying length --- src/ps-tree2.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ps-tree2.js b/src/ps-tree2.js index b164a83..efd222d 100644 --- a/src/ps-tree2.js +++ b/src/ps-tree2.js @@ -75,7 +75,6 @@ class LinesToPsObjectsArrayTransform extends Transform { */ _transform(chunk, encoding, callback) { let line = Buffer.isBuffer(chunk) ? chunk.toString(): chunk; - line = line.trim(); // Remove unnecessary lines created by powershell if ((line.length === 0) || line.includes('----')) { @@ -155,7 +154,7 @@ class LinesToPsObjectsArrayTransform extends Transform { // last column const header = line.substring(startOfColumnIncl, line.length).trim(); - columnDefinitions.push({start:startOfColumnIncl, end:line.length, header:header}); + columnDefinitions.push({start:startOfColumnIncl, end:undefined, header:header}); return columnDefinitions; }