├── diagram1.png ├── .gitignore ├── docker-compose.yml ├── Dockerfile ├── package.json ├── example.js ├── example_key_thumb_auth.js ├── example_key_auth.js ├── README.md ├── psCommandService.js ├── test ├── all.js └── unit.js └── o365Utils.js /diagram1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitsofinfo/powershell-command-executor/HEAD/diagram1.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js Tools for Visual Studio 2 | .ntvs_analysis.dat 3 | node_modules/ 4 | 5 | *.code-workspace 6 | 7 | # Visual Studio cache files 8 | # files ending in .cache can be ignored 9 | *.[Cc]ache 10 | # but keep track of directories ending in .cache 11 | !?*.[Cc]ache/ -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | test: 4 | build: . 5 | volumes: 6 | - .:/app 7 | - /app/node_modules 8 | environment: 9 | - APPLICATION_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxx 10 | - TENANT=XXXXXXXXXXXXXXXXXXXXXXXXXXXX 11 | - CERTIFICATE_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXX 12 | - CERTIFICATE=XXXXXXXXXXXXXXXXXXXXXXXXXXXX 13 | - O365_TENANT_DOMAIN_NAME=sample.com -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/powershell:7.4-mariner-2.0-arm64 2 | 3 | ENV APP_ROOT_DIR="/app" 4 | 5 | RUN pwsh -Command Set-PSRepository -Name PSGallery -InstallationPolicy Trusted && \ 6 | pwsh -Command Install-Module -Name ExchangeOnlineManagement -Scope AllUsers -RequiredVersion 3.5.0 && \ 7 | pwsh -Command Set-PSRepository -Name PSGallery -InstallationPolicy Untrusted 8 | 9 | RUN yum install -y nodejs npm 10 | 11 | # Set the working directory in the container 12 | WORKDIR /app 13 | 14 | # Copy package.json and package-lock.json 15 | COPY package*.json ./ 16 | 17 | # Install dependencies 18 | RUN npm install 19 | 20 | # Copy the rest of the application code 21 | COPY . . 22 | 23 | # Command to run tests 24 | CMD ["npm", "run", "test-docker"] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "powershell-command-executor", 3 | "version": "1.1.4", 4 | "description": "Provides a registry and gateway for execution powershell commands through long-lived established remote PSSessions via a stateful-process-command-proxy pool of powershell processes", 5 | "main": "psCommandService.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "test": "mocha test/all.js", 11 | "test-docker": "mocha test/unit.js" 12 | }, 13 | "keywords": [ 14 | "command", 15 | "process", 16 | "os", 17 | "dos", 18 | "commandline", 19 | "command line", 20 | "shell", 21 | "windows", 22 | "powershell", 23 | "execute", 24 | "exec" 25 | ], 26 | "dependencies": { 27 | "mustache": "^4.1.0", 28 | "promise": "latest", 29 | "stateful-process-command-proxy": "latest" 30 | }, 31 | "devDependencies": { 32 | "mocha": "latest" 33 | }, 34 | "contributors": [ 35 | { 36 | "name": "bitsofinfo", 37 | "url": "http://bitsofinfo.wordpress.com" 38 | } 39 | ], 40 | "license": "ISC", 41 | "repository": { 42 | "type": "git", 43 | "url": "https://github.com/bitsofinfo/powershell-command-executor.git" 44 | }, 45 | "bugs": { 46 | "url": "https://github.com/bitsofinfo/powershell-command-executor/issues" 47 | }, 48 | "homepage": "https://github.com/bitsofinfo/powershell-command-executor" 49 | } 50 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | var StatefulProcessCommandProxy = require("stateful-process-command-proxy"); 2 | var PSCommandService = require('./psCommandService'); 3 | var o365Utils = require('./o365Utils'); 4 | 5 | 6 | 7 | 8 | var statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 9 | name: "StatefulProcessCommandProxy", 10 | max: 1, 11 | min: 1, 12 | idleTimeoutMS:120000, 13 | log: function(severity,origin,msg) { 14 | console.log(severity.toUpperCase() + " " +origin+" "+ msg); 15 | }, 16 | 17 | processCommand: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', 18 | processArgs: ['-Command','-'], 19 | 20 | 21 | processRetainMaxCmdHistory : 20, 22 | processInvalidateOnRegex : { 23 | 'any':[], 24 | 'stdout':[], 25 | 'stderr':[{'regex':'.*error.*'}] 26 | }, 27 | processCwd : null, 28 | processEnvMap : null, 29 | processUid : null, 30 | processGid : null, 31 | 32 | initCommands: o365Utils.getO365PSInitCommands( 33 | 'C:\\pathto\\decryptUtil.ps1', 34 | 'C:\\pathto\\encrypted.credentials', 35 | 'C:\\pathto\\secret.key', 36 | 10000,30000,60000), 37 | 38 | 39 | validateFunction: function(processProxy) { 40 | var isValid = processProxy.isValid(); 41 | if(!isValid) { 42 | console.log("ProcessProxy.isValid() returns FALSE!"); 43 | } 44 | return isValid; 45 | }, 46 | 47 | 48 | preDestroyCommands: o365Utils.getO365PSDestroyCommands(), 49 | 50 | processCmdWhitelistRegex: o365Utils.getO365WhitelistedCommands(), 51 | 52 | processCmdBlacklistRegex: o365Utils.getO365BlacklistedCommands(), 53 | 54 | autoInvalidationConfig: o365Utils.getO365AutoInvalidationConfig(30000) 55 | 56 | }); 57 | 58 | var myLogFunction = function(severity,origin,message) { 59 | console.log(severity.toUpperCase() + ' ' + origin + ' ' + message); 60 | } 61 | 62 | 63 | /** 64 | * Fetch a group! 65 | */ 66 | var psCommandService = new PSCommandService(statefulProcessCommandProxy, 67 | o365Utils.o365CommandRegistry, 68 | myLogFunction); 69 | 70 | psCommandService.execute('getDistributionGroup',{'Identity':"someGroupName"}) 71 | .then(function(groupJson) { 72 | console.log(groupJson); 73 | }).catch(function(error) { 74 | console.log(error); 75 | }); 76 | 77 | setTimeout(function(){statefulProcessCommandProxy.shutdown()},80000); 78 | -------------------------------------------------------------------------------- /example_key_thumb_auth.js: -------------------------------------------------------------------------------- 1 | var StatefulProcessCommandProxy = require("stateful-process-command-proxy"); 2 | var PSCommandService = require('./psCommandService'); 3 | var o365Utils = require('./o365Utils'); 4 | 5 | 6 | 7 | 8 | var statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 9 | name: "StatefulProcessCommandProxy", 10 | max: 1, 11 | min: 1, 12 | idleTimeoutMS:120000, 13 | log: function(severity,origin,msg) { 14 | console.log(severity.toUpperCase() + " " +origin+" "+ msg); 15 | }, 16 | 17 | processCommand: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', 18 | processArgs: ['-Command','-'], 19 | 20 | 21 | processRetainMaxCmdHistory : 20, 22 | processInvalidateOnRegex : { 23 | 'any':[], 24 | 'stdout':[], 25 | 'stderr':[{'regex':'.*error.*'}] 26 | }, 27 | processCwd : null, 28 | processEnvMap : null, 29 | processUid : null, 30 | processGid : null, 31 | 32 | initCommands: o365Utils.getO365PSThumbprintInitCommands( 33 | 'C:\\pathto\\decryptUtil.ps1', 34 | 'C:\\pathto\\encrypted.credentials', 35 | 'C:\\pathto\\secret.key', 36 | 'certificatethumbprint', 37 | '00000000-00000000-00000000-00000000', 38 | 'your.exhange.domain.name', 39 | 10000,30000,60000), 40 | 41 | 42 | validateFunction: function(processProxy) { 43 | var isValid = processProxy.isValid(); 44 | if(!isValid) { 45 | console.log("ProcessProxy.isValid() returns FALSE!"); 46 | } 47 | return isValid; 48 | }, 49 | 50 | 51 | preDestroyCommands: o365Utils.getO365PSThumbprintDestroyCommands(), 52 | 53 | processCmdWhitelistRegex: o365Utils.getO365WhitelistedCommands(), 54 | 55 | processCmdBlacklistRegex: o365Utils.getO365BlacklistedCommands(), 56 | 57 | autoInvalidationConfig: o365Utils.getO365AutoInvalidationConfig(30000) 58 | 59 | }); 60 | 61 | var myLogFunction = function(severity,origin,message) { 62 | console.log(severity.toUpperCase() + ' ' + origin + ' ' + message); 63 | } 64 | 65 | 66 | /** 67 | * Fetch a group! 68 | */ 69 | var psCommandService = new PSCommandService(statefulProcessCommandProxy, 70 | o365Utils.o365CommandRegistry, 71 | myLogFunction); 72 | 73 | psCommandService.execute('getDistributionGroup',{'Identity':"someGroupName"}) 74 | .then(function(groupJson) { 75 | console.log(groupJson); 76 | }).catch(function(error) { 77 | console.log(error); 78 | }); 79 | 80 | setTimeout(function(){statefulProcessCommandProxy.shutdown()},80000); 81 | -------------------------------------------------------------------------------- /example_key_auth.js: -------------------------------------------------------------------------------- 1 | var StatefulProcessCommandProxy = require("stateful-process-command-proxy"); 2 | var PSCommandService = require('./psCommandService'); 3 | var o365Utils = require('./o365Utils'); 4 | 5 | 6 | 7 | 8 | var statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 9 | name: "StatefulProcessCommandProxy", 10 | max: 1, 11 | min: 1, 12 | idleTimeoutMS:120000, 13 | log: function(severity,origin,msg) { 14 | console.log(severity.toUpperCase() + " " +origin+" "+ msg); 15 | }, 16 | 17 | processCommand: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', 18 | processArgs: ['-Command','-'], 19 | 20 | 21 | processRetainMaxCmdHistory : 20, 22 | processInvalidateOnRegex : { 23 | 'any':[], 24 | 'stdout':[], 25 | 'stderr':[{'regex':'.*error.*'}] 26 | }, 27 | processCwd : null, 28 | processEnvMap : null, 29 | processUid : null, 30 | processGid : null, 31 | 32 | initCommands: o365Utils.getO365PSKeyInitCommands( 33 | 'C:\\pathto\\decryptUtil.ps1', 34 | 'C:\\pathto\\encrypted.credentials', 35 | 'C:\\pathto\\secret.key', 36 | 'C:\\pathto\\certificate', 37 | 'certificatePassword', 38 | '00000000-00000000-00000000-00000000', 39 | 'your.exhange.domain.name', 40 | 10000,30000,60000), 41 | 42 | 43 | validateFunction: function(processProxy) { 44 | var isValid = processProxy.isValid(); 45 | if(!isValid) { 46 | console.log("ProcessProxy.isValid() returns FALSE!"); 47 | } 48 | return isValid; 49 | }, 50 | 51 | 52 | preDestroyCommands: o365Utils.getO365PSKeyDestroyCommands(), 53 | 54 | processCmdWhitelistRegex: o365Utils.getO365WhitelistedCommands(), 55 | 56 | processCmdBlacklistRegex: o365Utils.getO365BlacklistedCommands(), 57 | 58 | autoInvalidationConfig: o365Utils.getO365AutoInvalidationConfig(30000) 59 | 60 | }); 61 | 62 | var myLogFunction = function(severity,origin,message) { 63 | console.log(severity.toUpperCase() + ' ' + origin + ' ' + message); 64 | } 65 | 66 | 67 | /** 68 | * Fetch a group! 69 | */ 70 | var psCommandService = new PSCommandService(statefulProcessCommandProxy, 71 | o365Utils.o365CommandRegistry, 72 | myLogFunction); 73 | 74 | psCommandService.execute('getDistributionGroup',{'Identity':"someGroupName"}) 75 | .then(function(groupJson) { 76 | console.log(groupJson); 77 | }).catch(function(error) { 78 | console.log(error); 79 | }); 80 | 81 | setTimeout(function(){statefulProcessCommandProxy.shutdown()},80000); 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # powershell-command-executor 2 | 3 | Node.js module that provides a registry and gateway for execution of pre-defined powershell commands through long-lived established remote PSSessions. 4 | 5 | [![NPM](https://nodei.co/npm/powershell-command-executor.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/powershell-command-executor/) 6 | 7 | * [Overview](#overview) 8 | * [Concepts](#concepts) 9 | * [Usage](#usage) 10 | * [Testing](#testing) 11 | * [History](#history) 12 | * [Related tools](#related) 13 | 14 | ### Overview 15 | 16 | This Node.js module builds on top of [stateful-process-command-proxy](https://github.com/bitsofinfo/stateful-process-command-proxy) to provide a higher level API for a registry of pre-defined commands, specifically for various powershell operations agains Office365; or any powershell command really, you just need to configure them. The module provides a simplified interface to pass arguments to various "named" commands, sanitize the arguments and return the results. This module supports concepts that would permit the construction of a higher level interface to this system, such as via a REST API or user interface... see [powershell-command-executor-ui](https://github.com/bitsofinfo/powershell-command-executor-ui) for a working example of this concept in an useable implementation. 17 | 18 | ![Alt text](/diagram1.png "Diagram1") 19 | 20 | ### Concepts 21 | 22 | #### psCommandExecutor.js 23 | 24 | This provides the PSCommandService class which is a wrapper around [StatefulProcessCommandProxy](https://github.com/bitsofinfo/stateful-process-command-proxy) which lets a caller invoke "named" commands passing an map/hash of arguments. PSCommandService will generate the actual command and pass it to the StatefulProcessCommandProxy for execution and return the results. PSCommandService must be created passing an configured instance of [StatefulProcessCommandProxy](https://github.com/bitsofinfo/stateful-process-command-proxy) and a "registry" of commands. You can see an example of what a command registry looks like within ```o365Utils.js```. You don't have to use the latter registry.. you can create your own or just augment it with your own set of commands that you want to make available through PSCommandService. 25 | 26 | #### o365Utils.js 27 | 28 | This script simply exports a few useful pre-defined parameter sets (that one would pass to the constructor of StatefulProcessComamndProxy) for the initialization, destruction and auto-invalidation of "powershell" processes who connect to o365 and establish a remote PSSession that will be long lived. (and validate that the session is still legit) 29 | 30 | #### Exchange authentication 31 | 32 | `o365Utils.js` init command `getO365PSInitCommands` is using a deprecated authentication [method](https://techcommunity.microsoft.com/t5/exchange-team-blog/modern-auth-and-unattended-scripts-in-exchange-online-powershell/ba-p/1497387) 33 | 34 | Mictosoft has added [Exchange Online PowerShell V2](https://techcommunity.microsoft.com/t5/exchange-team-blog/announcing-general-availability-of-the-exchange-online/ba-p/1436623) that supports cerificate based authentication. 35 | 36 | Full setup is descibed [here](https://adamtheautomator.com/exchange-online-powershell-mfa/) 37 | 38 | Three sets of init commands are availiable as of version `1.1.0`: 39 | 40 | * `getO365PSInitCommands` - backward compatible old basic authentication 41 | * `getO365PSKeyInitCommands` - new Exchange authentication with private key and password 42 | * `getO365PSThumbprintInitCommands` - new Exchange authentication with the thumb print for the certificate 43 | 44 | ### Usage 45 | 46 | 1) Configure your o365 tenant with a user with the appropriate permissions to manage o365 via Powershell. [See this article to get going](https://bitsofinfo.wordpress.com/2015/01/06/configuring-powershell-for-azure-ad-and-o365-exchange-management/) 47 | 48 | 2) Use [powershell-credential-encryption-tools](https://github.com/bitsofinfo/powershell-credential-encryption-tools) to create an encrypted credentials file and secret key for decryption. SECURE these files! 49 | 50 | 3) From within this project install the necessary npm dependencies for this module, including [stateful-process-command-proxy](https://github.com/bitsofinfo/stateful-process-command-proxy). You can checkout the latter manually and do a ```npm install stateful-process-command-proxy``` 51 | 52 | 4) Configure ```example.js```/```example_key_auth.js```/```examplekey_thumb_auth.js``` appropriately, in particular the ```initCommands``` for the StatefulProcessCommandProxy; the paths to the items you created via the second step above 53 | 54 | 5) Tweak the group that is fetched at the bottom of ```example.js```/```example_key_auth.js```/```examplekey_thumb_auth.js``` 55 | 56 | 7) There is also a unit-test (```test\all.js```) for the command registry in ```o365Utils.js``` which gives an example of usage for all thre possible Exchange connect variations. 57 | 58 | ### Testing 59 | Project test can be executed by running `npm test` command on Windows machine. Connection to Exchange Online is required for the tests to pass. 60 | 61 | There is also option to run Docker based tests. You need to configure `environment` variables in `docker-compose.yml` file in order to define connection parameters. To run tests in Docker container, execute `docker-compose run test` command once the configuration is done. 62 | 63 | Exchange online tests will be skipped if the connection is not available. 64 | 65 | ### Empty Argument Values Support 66 | 67 | As of version 1.1.5, the module now supports passing empty string values to PowerShell command arguments when explicitly configured. This is useful for optional parameters that need to be passed as empty values rather than omitted entirely. 68 | 69 | To enable empty value support for a command argument, set the `empty` property to `true` in the argument configuration: 70 | 71 | ```javascript 72 | const commandRegistry = { 73 | 'myCommand': { 74 | command: "Get-Content {{{arguments}}}", 75 | arguments: { 76 | 'Path': {}, 77 | 'Filter': { 78 | empty: true, // Allow empty string values 79 | }, 80 | }, 81 | return: { 82 | type: "text", 83 | } 84 | } 85 | }; 86 | ``` 87 | 88 | When `empty: true` is set, the argument will accept empty string values and include them in the generated PowerShell command: 89 | 90 | ```javascript 91 | // This will generate: Get-Content -Path './test.txt' -Filter '' 92 | await psCommandService.execute("myCommand", { 93 | Path: "./test.txt", 94 | Filter: "" // Empty string value is now allowed 95 | }); 96 | ``` 97 | 98 | 99 | 100 | ### History 101 | 102 | ``` 103 | v1.1.5 - 2025-09-19 104 | - Added support for empty argument values in commands via 'empty' property 105 | - Fixed argument value bleed into the next empty argument 106 | 107 | v1.1.4 - 2024-11-22 108 | - Extended testing and fixed escaping reserved variables and special characters in commands 109 | 110 | v1.1.3 - 2024-11-14 111 | - Added support for [multivalued parameters](https://learn.microsoft.com/en-us/exchange/modifying-multivalued-properties-exchange-2013-help) in commands 112 | 113 | v1.1.2 - 2022-07-06 114 | - Added support for usage of reserved powershell variables in commands [$null, $true, $false] 115 | 116 | 117 | v1.1.1 - 2020-12-07 118 | - Fixed bug import of custom commands if provided for certificate based auth 119 | 120 | v1.1.0 - 2020-12-03 121 | - Added option for key and thumbprint based Exchange authentication 122 | 123 | v1.0.0 - 2016-06-08 124 | - Get-DistributionGroupMember - added "-ResultSize Unlimited" 125 | 126 | v1.0-beta.7 - 2015-02-10 127 | - Add semi-colins to sanitization 128 | 129 | v1.0-beta.6 - 2015-02-06 130 | - Bug fix to injection 131 | 132 | v1.0-beta.5 - 2015-02-06 133 | - Further improvement for argument injection 134 | 135 | v1.0-beta.4 - 2015-02-05 136 | - Fixes to quote sanitization, bug fixes 137 | 138 | v1.0-beta.3 - 2015-01-30 139 | - Tweaks to init commands 140 | 141 | v1.0-beta.2 - 2015-01-28 142 | - Whitelisting of commands 143 | 144 | v1.0-beta.1 - 2015-01-28 145 | - Initial version 146 | ``` 147 | 148 | ### Related Tools 149 | 150 | Have a look at these related projects which support and build on top of this module to provide more functionality 151 | 152 | * https://github.com/bitsofinfo/stateful-process-command-proxy - The core dependency of this module, provides the actual bridging between node.js and a pool of external shell processes 153 | * https://github.com/bitsofinfo/powershell-command-executor-ui - Builds on top of powershell-command-executor to provide a simple Node REST API and AngularJS interface for testing the execution of commands in the registry 154 | * https://github.com/bitsofinfo/meteor-shell-command-mgr - Small Meteor app that lets you manage/generate a command registry for powershell-command-executor 155 | 156 | ## notes 157 | 158 | ``` 159 | npm login 160 | npm publish 161 | ``` 162 | -------------------------------------------------------------------------------- /psCommandService.js: -------------------------------------------------------------------------------- 1 | module.exports = PSCommandService; 2 | 3 | var Promise = require('promise'); 4 | var Mustache = require('mustache'); 5 | 6 | /** 7 | * Reserved variables in Powershell to allow as arguments 8 | * @see https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.2 9 | */ 10 | const reservedVariableNames = ['$null', '$false', '$true']; 11 | 12 | /** 13 | * PSCommandService 14 | * 15 | * @param statefulProcessCommandProxy all commands will be executed over this 16 | * 17 | * @param commandRegistry registry/hash of Powershell commands 18 | * @see o365CommandRegistry.js for examples 19 | * 20 | * @param logFunction optional function that should have the signature 21 | * (severity,origin,message), where log messages will 22 | * be sent to. If null, logs will just go to console 23 | * 24 | */ 25 | function PSCommandService(statefulProcessCommandProxy,commandRegistry,logFunction) { 26 | this._statefulProcessCommandProxy = statefulProcessCommandProxy; 27 | this._commandRegistry = commandRegistry; 28 | this._logFunction = logFunction; 29 | } 30 | 31 | // log function for no origin 32 | PSCommandService.prototype._log = function(severity,msg) { 33 | this._log2(severity,this.__proto__.constructor.name,msg); 34 | } 35 | 36 | 37 | // Log function w/ origin 38 | PSCommandService.prototype._log2 = function(severity,origin,msg) { 39 | if (this._logFunction) { 40 | this._logFunction(severity,origin,msg); 41 | 42 | } else { 43 | console.log(severity.toUpperCase() + " " + origin + " " + msg); 44 | } 45 | } 46 | 47 | 48 | /** 49 | * Returns an array of all available command objects 50 | * 51 | * { commandName:name, command:commandString, arguments:{}, return: {} } 52 | * 53 | */ 54 | PSCommandService.prototype.getAvailableCommands = function() { 55 | var commands = []; 56 | for (var cmd in this._commandRegistry) { 57 | commands.push({ 58 | 'commandName' : cmd, 59 | 'command' : this._commandRegistry[cmd].command, 60 | 'arguments' : this._commandRegistry[cmd].arguments, 61 | 'return' : this._commandRegistry[cmd].return 62 | }); 63 | 64 | } 65 | 66 | return commands; 67 | } 68 | 69 | /** 70 | * getStatus() 71 | * 72 | * Return the status of all managed processes, an array 73 | * of structured ProcessProxy status objects 74 | */ 75 | PSCommandService.prototype.getStatus = function() { 76 | var status = this._statefulProcessCommandProxy.getStatus(); 77 | return status; 78 | } 79 | 80 | // get a CommandConfig by commandName, throws error otherwise 81 | PSCommandService.prototype._getCommandConfig = function(commandName) { 82 | var commandConfig = this._commandRegistry[commandName]; 83 | if (!commandConfig) { 84 | var msg = ("No command registered by name: " + commandName); 85 | this._log('error',msg) 86 | throw new Error(msg); 87 | } 88 | return commandConfig; 89 | } 90 | 91 | /** 92 | * generateCommand() 93 | * 94 | * Generates an actual powershell command as registered in the 95 | * command registry, applying the values from the argument map 96 | * returns a literal command string that can be executed 97 | * 98 | * 99 | * @param commandName 100 | * @param argument2ValueMap 101 | * @return command generated, otherwise Error if command not found 102 | */ 103 | PSCommandService.prototype.generateCommand = function(commandName, argument2ValueMap) { 104 | var commandConfig = this._getCommandConfig(commandName); 105 | var generated = this._generateCommand(commandConfig, argument2ValueMap); 106 | return generated; 107 | } 108 | 109 | /** 110 | * execute() 111 | * 112 | * Executes a named powershell command as registered in the 113 | * command registry, applying the values from the argument map 114 | * returns a promise that when fulfilled returns the cmdResult 115 | * object from the command which contains properties 116 | * {commandName: name, command:generatedCommand, stdout:xxxx, stderr:xxxxx} 117 | * 118 | * On reject an Error object 119 | * 120 | * @param array of commands 121 | */ 122 | PSCommandService.prototype.execute = function(commandName, argument2ValueMap) { 123 | var command = this.generateCommand(commandName, argument2ValueMap); 124 | var self = this; 125 | return new Promise(function(fulfill,reject) { 126 | self._execute(command) 127 | .then(function(cmdResult) { 128 | // tack on commandName 129 | cmdResult['commandName'] = commandName; 130 | fulfill(cmdResult); 131 | }).catch(function(error){ 132 | reject(error); 133 | }); 134 | }); 135 | } 136 | 137 | /** 138 | * executeAll() 139 | * 140 | * Expects an array of commandNames -> argMaps to execute in order 141 | * [ 142 | * {commandName: name1, argMap: {param:value, param:value, ...}}, 143 | * {commandName: name2, argMap: {param:value, param:value, ...}}, 144 | * ] 145 | * 146 | * Executes the named powershell commands as registered in the 147 | * command registry, applying the values from the argument maps 148 | * returns a promise that when fulfilled returns an cmdResults array 149 | * where each entry contains 150 | * [ 151 | * {commandName: name1, command:cmd1, stdout:xxxx, stderr:xxxxx}, 152 | * {commandName: name2, command:cmd2, stdout:xxxx, stderr:xxxxx} 153 | * ] 154 | * 155 | * On reject an Error object 156 | * 157 | * @param array of {commandName -> arglist} 158 | */ 159 | PSCommandService.prototype.executeAll = function(cmdName2ArgValuesList) { 160 | 161 | var commandsToExec = []; 162 | 163 | for (var i=0; i : {command: , stdout: value, stderr: value }} 230 | * 231 | * On reject an Error object 232 | * 233 | * @param array of commands 234 | */ 235 | PSCommandService.prototype._executeCommands = function(commands) { 236 | var self = this; 237 | 238 | var logBuffer = ""; 239 | for (var i=0; i values (valid for the passed commandConfig) 261 | * 262 | * @return a formatted powershell command string suitable for execution 263 | * 264 | * @throws Error if any exception occurs 265 | * 266 | * !!!! TODO: review security protection for "injection" (i.e command termination, newlines etc) 267 | */ 268 | PSCommandService.prototype._generateCommand = function(commandConfig, argument2ValueMap) { 269 | 270 | try { 271 | var argumentsConfig = commandConfig.arguments; 272 | 273 | var argumentsString = ""; 274 | 275 | for (var argumentName in argumentsConfig) { 276 | 277 | if(argumentsConfig.hasOwnProperty(argumentName)) { 278 | 279 | var argument = argumentsConfig[argumentName]; 280 | 281 | // is argument valued 282 | if ((argument.hasOwnProperty('valued') ? argument.valued : true)) { 283 | 284 | var isQuoted = (argument.hasOwnProperty('quoted') ? argument.quoted : true); 285 | var isEmpty = (argument.hasOwnProperty('empty') ? argument.empty : false); 286 | var passedArgValues = argument2ValueMap[argumentName]; 287 | 288 | if (!(passedArgValues instanceof Array)) { 289 | 290 | if (typeof passedArgValues === 'undefined') { 291 | 292 | if (argument.hasOwnProperty('default')) { 293 | passedArgValues = [argument.default]; 294 | } else { 295 | passedArgValues = []; 296 | } 297 | 298 | } else { 299 | passedArgValues = [passedArgValues]; 300 | } 301 | } 302 | 303 | var argumentValues = ""; 304 | for (var i=0; i 0)) { 319 | 320 | // sanitize 321 | valueToSet = this._sanitize(valueToSet,isQuoted); 322 | 323 | // append w/ quotes (SINGLE QUOTES, not double to avoid expansion) 324 | argumentValues += (this._finalizeParameterValue(valueToSet,isQuoted) + ","); 325 | } 326 | } 327 | 328 | // were values appended? 329 | if (argumentValues.length > 0) { 330 | 331 | // append to arg string 332 | argumentsString += (("-"+argumentName+" ") + argumentValues); 333 | 334 | if (argumentsString.lastIndexOf(',') == (argumentsString.length -1)) { 335 | argumentsString = argumentsString.substring(0,argumentsString.length-1); 336 | } 337 | argumentsString += " "; 338 | } 339 | 340 | // argument is NOT valued, just append the name 341 | } else { 342 | argumentsString += ("-"+argumentName+" "); 343 | } 344 | 345 | } 346 | 347 | } 348 | 349 | return Mustache.render(commandConfig.command,{'arguments':argumentsString}); 350 | 351 | } catch(exception) { 352 | var msg = ("Unexpected error in _generateCommand(): " + exception + ' ' + exception.stack); 353 | this._log('error',msg) 354 | throw new Error(msg); 355 | } 356 | } 357 | 358 | PSCommandService.prototype._finalizeParameterValue = function(valueToSet, applyQuotes) { 359 | valueToSet = ((applyQuotes?"'":'')+valueToSet+(applyQuotes?"'":'')); 360 | 361 | return valueToSet; 362 | } 363 | 364 | PSCommandService.prototype._sanitize = function (toSanitize, isQuoted) { 365 | toSanitize = toSanitize 366 | .replace(/[\n\r]/g, "") // kill true newlines/feeds 367 | .replace(/\\n/g, "\\$&") // kill string based newline attempts 368 | .replace(/[`#]/g, "`$&"); // escape stuff that could screw up variables 369 | 370 | const sanitizeRegex = /[;\$\|\(\)\{\}\[\]\\]/g; 371 | const multiValuedRegex = /@\{([^}]*)\}/g; 372 | 373 | if (isQuoted) { // if quoted, escape all quotes 374 | toSanitize = toSanitize.replace(/'/g, "'$&"); 375 | } else if (multiValuedRegex.test(toSanitize)) { 376 | // process is this is multi-valued parameter 377 | const extractParams = (str, key) => { 378 | // values must be wrapped in double quotes, so we can split them by comma 379 | const match = str.match(new RegExp(`${key}="([^;]+)(?:";|"})`, "i")); 380 | return match 381 | ? match[1] 382 | .split(",") 383 | .map((param) => 384 | param.trim().replace(sanitizeRegex, "`$&").replace(/^"|"$/g, "") 385 | ) 386 | : []; 387 | }; 388 | 389 | const addItemsSanitized = extractParams(toSanitize, "Add"); 390 | const removeItemsSanitized = extractParams(toSanitize, "Remove"); 391 | if (addItemsSanitized.length > 0 || removeItemsSanitized.length > 0) { 392 | let result = "@{"; 393 | if (addItemsSanitized.length > 0) { 394 | result += `Add="${addItemsSanitized.join('","')}"`; 395 | } 396 | if (removeItemsSanitized.length > 0) { 397 | if (addItemsSanitized.length > 0) result += "; "; 398 | result += `Remove="${removeItemsSanitized.join('","')}"`; 399 | } 400 | result += "}"; 401 | toSanitize = result; 402 | } 403 | } else if (!reservedVariableNames.includes(toSanitize)) { // skip if this is reserved variable name 404 | toSanitize = toSanitize.replace(sanitizeRegex, "`$&"); 405 | } 406 | 407 | return toSanitize; 408 | }; 409 | -------------------------------------------------------------------------------- /test/all.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var o365Utils = require('../o365Utils'); 3 | var PSCommandService = require('../psCommandService'); 4 | 5 | /** 6 | * IMPORTANT! 7 | * To run this test, you need to configure 8 | * the following 4 variables! 9 | * 10 | * The credentials you are using to access o365 should 11 | * be for a user that is setup as follows @: 12 | * https://bitsofinfo.wordpress.com/2015/01/06/configuring-powershell-for-azure-ad-and-o365-exchange-management/ 13 | * 14 | * @see https://github.com/bitsofinfo/powershell-credential-encryption-tools 15 | */ 16 | var PATH_TO_DECRYPT_UTIL_SCRIPT = 'C:\\pathto\\decryptUtil.ps1'; 17 | var PATH_TO_ENCRYPTED_CREDENTIALS = 'C:\\pathto\\encrypted.credentials'; 18 | var PATH_TO_SECRET_KEY = 'C:\\pathto\\secret.key'; 19 | var O365_TENANT_DOMAIN_NAME = "somedomain.com"; 20 | 21 | /** 22 | * Following variables needed to test Certificate based connection to Exchange server 23 | * 24 | * @see https: //adamtheautomator.com/exchange-online-powershell-mfa/ 25 | * for setup instructions 26 | */ 27 | var PATH_TO_AUTH_CERTIFICATE = 'C:\\pathto\\certificate'; 28 | var CERTIFICATE_PASSWORD = 'xxxxxx'; 29 | var CERTIFICATE_THUMBPRINT = 'xxxxxxxxxx'; 30 | var APPLICATION_ID = '00000000-00000000-00000000-00000000'; 31 | var TENANT_ID = 'your.exhange.domain.name'; 32 | 33 | var testRun = function (done, initCommands, preDestroyCommands) { 34 | var StatefulProcessCommandProxy = require("stateful-process-command-proxy"); 35 | 36 | // configure our proxy/pool of processes 37 | var statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 38 | name: "o365 RemotePSSession powershell pool", 39 | max: 1, 40 | min: 1, 41 | idleTimeoutMS: 30000, 42 | 43 | logFunction: function (severity, origin, msg) { 44 | if (origin != 'Pool') { 45 | console.log(severity.toUpperCase() + " " + origin + " " + msg); 46 | } 47 | }, 48 | 49 | processCommand: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', 50 | processArgs: ['-Command', '-'], 51 | 52 | processRetainMaxCmdHistory: 30, 53 | processInvalidateOnRegex: { 54 | 'any': [{ 55 | 'regex': '.*nomatch.*', 56 | 'flags': 'i' 57 | }], 58 | 'stdout': [{ 59 | 'regex': '.*nomatch.*' 60 | }], 61 | 'stderr': [{ 62 | 'regex': '.*nomatch.*' 63 | }] 64 | }, 65 | processCwd: null, 66 | processEnvMap: null, 67 | processUid: null, 68 | processGid: null, 69 | 70 | initCommands: initCommands, 71 | 72 | validateFunction: function (processProxy) { 73 | return processProxy.isValid(); 74 | }, 75 | 76 | preDestroyCommands: preDestroyCommands, 77 | 78 | processCmdBlacklistRegex: o365Utils.getO365BlacklistedCommands(), 79 | 80 | processCmdWhitelistRegex: o365Utils.getO365WhitelistedCommands(), 81 | 82 | autoInvalidationConfig: o365Utils.getO365AutoInvalidationConfig(30000) 83 | 84 | }); 85 | 86 | var myLogFunction = function (severity, origin, message) { 87 | console.log(severity.toUpperCase() + ' ' + origin + ' ' + message); 88 | }; 89 | 90 | // create our PSCommandService 91 | var psCommandService = new PSCommandService(statefulProcessCommandProxy, 92 | o365Utils.o365CommandRegistry, 93 | myLogFunction); 94 | 95 | // random seed for generated data 96 | var random = "unitTest" + Math.abs(Math.floor(Math.random() * (1000 - 99999 + 1) + 1000)); 97 | 98 | var testUserName = "auser-" + random; 99 | var testUserEmail = testUserName + "@" + O365_TENANT_DOMAIN_NAME; 100 | 101 | var testUser2Name = "auser2-" + random; 102 | var testUser2Email = testUser2Name + "@" + O365_TENANT_DOMAIN_NAME; 103 | 104 | var testMailContactName = "amailContact-" + random; 105 | var testMailContactEmail = testMailContactName + "@" + O365_TENANT_DOMAIN_NAME; 106 | 107 | var testGroupName = "agroup-" + random; 108 | var testGroupEmail = testGroupName + "@" + O365_TENANT_DOMAIN_NAME; 109 | 110 | // total hack, needed due to deplays on ms side 111 | var sleep = function (milliseconds) { 112 | var start = new Date().getTime(); 113 | var c = 0; 114 | for (var i = 0; i < 1e7; i++) { 115 | if ((new Date().getTime() - start) > milliseconds) { 116 | break; 117 | 118 | } else { 119 | console.log("SLEEP...."); 120 | } 121 | } 122 | }; 123 | 124 | 125 | var evalCmdResult = function (cmdResult, doWithCmdResult) { 126 | if (cmdResult.stderr && cmdResult.stderr.length > 0) { 127 | console.log("Stderr received: " + cmdResult.stderr); 128 | assert(false); 129 | // otherwise assume ok 130 | } else { 131 | return doWithCmdResult(cmdResult); 132 | } 133 | }; 134 | 135 | var evalCmdResults = function (cmdResults, doWithCmdResults) { 136 | 137 | var hasErrors = false; 138 | for (var i = 0; i < cmdResults.length; i++) { 139 | var cmdResult = cmdResults[i]; 140 | if (cmdResult.stderr && cmdResult.stderr.length > 0) { 141 | console.log("Stderr received: " + cmdResult.stderr); 142 | hasErrors = true; 143 | } 144 | } 145 | 146 | if (hasErrors) { 147 | assert(false); 148 | // otherwise assume ok 149 | } else { 150 | return doWithCmdResults(cmdResults); 151 | } 152 | }; 153 | 154 | var cleanupAndShutdown = function (done, error) { 155 | psCommandService.execute('removeMsolUser', { 156 | 'UserPrincipalName': testUserEmail 157 | }); 158 | psCommandService.execute('removeMsolUser', { 159 | 'UserPrincipalName': testUser2Email 160 | }); 161 | psCommandService.execute('removeDistributionGroup', { 162 | 'Identity': testGroupEmail 163 | }); 164 | psCommandService.execute('removeMailContact', { 165 | 'Identity': testMailContactEmail 166 | }); 167 | 168 | // shut it all down 169 | setTimeout(function () { 170 | statefulProcessCommandProxy.shutdown(); 171 | }, 5000); 172 | 173 | setTimeout(function () { 174 | if (error) { 175 | done(error); 176 | } else { 177 | done(); 178 | } 179 | 180 | }, 10000); 181 | 182 | // throw, it will stop the rest of the execution. 183 | if (error) { 184 | throw error; 185 | } 186 | }; 187 | 188 | // #1 create test users that we will use 189 | var promise = psCommandService.executeAll( 190 | [{ 191 | 'commandName': 'newMsolUser', 192 | 'argMap': { 193 | 'DisplayName': testUserName, 194 | 'UserPrincipalName': testUserEmail 195 | } 196 | }, 197 | { 198 | 'commandName': 'newMsolUser', 199 | 'argMap': { 200 | 'DisplayName': testUser2Name, 201 | 'UserPrincipalName': testUser2Email 202 | } 203 | }, 204 | ]) 205 | // handle newMsolUsers results... if ok getMsolUsers 206 | .then(function (cmdResults) { 207 | 208 | return evalCmdResults(cmdResults, function (cmdResults) { 209 | try { 210 | assert.equal(2, cmdResults.length); 211 | } catch (e) { 212 | cleanupAndShutdown(done, e); 213 | } 214 | console.log("msolUsers added OK: " + testUserEmail + " & " + testUser2Email); 215 | return psCommandService.executeAll( 216 | [{ 217 | 'commandName': 'getMsolUser', 218 | 'argMap': { 219 | 'UserPrincipalName': testUserEmail 220 | } 221 | }, 222 | { 223 | 'commandName': 'getMsolUser', 224 | 'argMap': { 225 | 'UserPrincipalName': testUser2Email 226 | } 227 | } 228 | ]); 229 | }); 230 | 231 | }) 232 | // handle getMsolUsers result... if ok create distributionGroup 233 | .then(function (cmdResults) { 234 | return evalCmdResults(cmdResults, function (cmdResults) { 235 | try { 236 | assert.equal(2, cmdResults.length); 237 | } catch (e) { 238 | cleanupAndShutdown(done, e); 239 | } 240 | for (var i = 0; i < cmdResults.length; i++) { 241 | var cmdResult = cmdResults[i]; 242 | var msolUser = JSON.parse(cmdResult.stdout); 243 | 244 | try { 245 | // check that either of our expected ones are in here... 246 | assert((testUserEmail == msolUser.UserPrincipalName) || (testUser2Email == msolUser.UserPrincipalName)); 247 | } catch (e) { 248 | cleanupAndShutdown(done, e); 249 | } 250 | } 251 | console.log("msolUsers fetched OK"); 252 | sleep(60000); 253 | return psCommandService.execute('newDistributionGroup', { 254 | 'Name': testGroupName, 255 | 'DisplayName': testGroupName, 256 | 'PrimarySmtpAddress': testGroupEmail, 257 | 'ManagedBy': testUserEmail, 258 | 'Members': testUserEmail 259 | }); 260 | }); 261 | }) 262 | // handle createDistributionResult ... if ok get distributionGroup 263 | .then(function (cmdResult) { 264 | return evalCmdResult(cmdResult, function (cmdResult) { 265 | var distributionGroup = JSON.parse(cmdResult.stdout); 266 | try { 267 | assert.equal(testGroupEmail, distributionGroup.PrimarySmtpAddress); 268 | } catch (e) { 269 | cleanupAndShutdown(done, e); 270 | } 271 | console.log("distributionGroup created OK: " + distributionGroup.PrimarySmtpAddress); 272 | return psCommandService.execute('getDistributionGroup', { 273 | 'Identity': testGroupEmail 274 | }); 275 | }); 276 | }) 277 | 278 | // handle getDistributionGroup ... if ok get addDistributionGroupMember 279 | // for user 1 and user 2 280 | .then(function (cmdResult) { 281 | return evalCmdResult(cmdResult, function (cmdResult) { 282 | var distributionGroup = JSON.parse(cmdResult.stdout); 283 | try { 284 | assert.equal(testGroupEmail, distributionGroup.PrimarySmtpAddress); 285 | } catch (e) { 286 | cleanupAndShutdown(done, e); 287 | } 288 | console.log("distributionGroup fetched OK: " + distributionGroup.PrimarySmtpAddress); 289 | return psCommandService.executeAll([{ 290 | 'commandName': 'addDistributionGroupMember', 291 | 'argMap': { 292 | 'Identity': testGroupEmail, 293 | 'Member': testUserEmail, 294 | 'BypassSecurityGroupManagerCheck': null, 295 | } 296 | }, 297 | { 298 | 'commandName': 'addDistributionGroupMember', 299 | 'argMap': { 300 | 'Identity': testGroupEmail, 301 | 'Member': testUser2Email, 302 | 'BypassSecurityGroupManagerCheck': null, 303 | } 304 | } 305 | ]); 306 | }); 307 | }) 308 | 309 | // handle addDistributionGroupMember ... if ok get getDistributionGroupMember 310 | .then(function (cmdResults) { 311 | return evalCmdResult(cmdResults, function (cmdResults) { 312 | console.log("distributionGroupMembers added OK"); 313 | return psCommandService.execute('getDistributionGroupMember', { 314 | 'Identity': testGroupEmail 315 | }); 316 | }); 317 | }) 318 | 319 | // handle getDistributionGrouMembers (should be 2) ... 320 | // if ok get removeDistributionGroupMember (user2) 321 | .then(function (cmdResult) { 322 | return evalCmdResult(cmdResult, function (cmdResult) { 323 | var members = JSON.parse(cmdResult.stdout); 324 | try { 325 | assert.equal(members.length, 2); 326 | } catch (e) { 327 | cleanupAndShutdown(done, e); 328 | } 329 | console.log("distributionGroup members fetched OK: " + members.length); 330 | return psCommandService.execute('removeDistributionGroupMember', { 331 | 'Identity': testGroupEmail, 332 | 'Member': testUser2Email 333 | }); 334 | }); 335 | 336 | }) 337 | 338 | // handle removeDistributionGroupMember ... 339 | // if ok get getDistributionGroupMember 340 | .then(function (cmdResult) { 341 | return evalCmdResult(cmdResult, function (cmdResult) { 342 | console.log("distributionGroupMember (user2) removed OK"); 343 | return psCommandService.execute('getDistributionGroupMember', { 344 | 'Identity': testGroupEmail 345 | }); 346 | }); 347 | }) 348 | 349 | // handle getDistributionGrouMembers (should now be 1) ... 350 | // if ok get newMailContact 351 | .then(function (cmdResult) { 352 | return evalCmdResult(cmdResult, function (cmdResult) { 353 | var members = JSON.parse("[" + cmdResult.stdout + "]"); 354 | try { 355 | assert.equal(members.length, 1); 356 | assert.equal(members[0].WindowsLiveID, testUserEmail); 357 | } catch (e) { 358 | cleanupAndShutdown(done, e); 359 | } 360 | console.log("getDistributionGroupMember fetched OK: only user1 remains " + members.length); 361 | return psCommandService.execute('newMailContact', { 362 | 'Name': testMailContactName, 363 | 'ExternalEmailAddress': testMailContactEmail, 364 | }); 365 | }); 366 | }) 367 | 368 | // handle newMailContact add 369 | // if ok get newMailContact 370 | .then(function (cmdResult) { 371 | return evalCmdResult(cmdResult, function (cmdResult) { 372 | console.log("newMailContact added OK: " + testMailContactEmail); 373 | return psCommandService.execute('getMailContact', { 374 | 'Identity': testMailContactEmail 375 | }); 376 | }); 377 | }) 378 | 379 | // handle getMailContact 380 | // if ok get addDistributionGroupMember 381 | .then(function (cmdResult) { 382 | return evalCmdResult(cmdResult, function (cmdResult) { 383 | var contact = JSON.parse(cmdResult.stdout); 384 | try { 385 | assert.equal(testMailContactEmail, contact.PrimarySmtpAddress); 386 | } catch (e) { 387 | cleanupAndShutdown(done, e); 388 | } 389 | console.log("getMailContact fetched OK: " + testMailContactEmail); 390 | return psCommandService.execute('addDistributionGroupMember', { 391 | 'Identity': testGroupEmail, 392 | 'Member': testMailContactEmail 393 | }); 394 | }); 395 | }) 396 | 397 | // handle addDistributionGroupMember 398 | // if ok get addDistributionGroupMember 399 | .then(function (cmdResult) { 400 | return evalCmdResult(cmdResult, function (cmdResult) { 401 | console.log("addDistributionGroupMember mailContact added OK: " + testMailContactEmail); 402 | return psCommandService.execute('getDistributionGroupMember', { 403 | 'Identity': testGroupEmail 404 | }); 405 | }); 406 | }) 407 | 408 | // handle getDistributionGrouMembers (should now be 2) ... 409 | // if ok get removeDistributionGroup 410 | .then(function (cmdResult) { 411 | return evalCmdResult(cmdResult, function (cmdResult) { 412 | var members = JSON.parse(cmdResult.stdout); 413 | try { 414 | assert.equal(members.length, 2); 415 | } catch (e) { 416 | cleanupAndShutdown(done, e); 417 | } 418 | console.log("getDistributionGroupMember fetched OK: one mail contact and one user exist " + members.length); 419 | return psCommandService.execute('removeDistributionGroup', { 420 | 'Identity': testGroupEmail 421 | }); 422 | }); 423 | }) 424 | 425 | // handle removeDistributionGroup then remove msoluser... 426 | .then(function (cmdResult) { 427 | return evalCmdResult(cmdResult, function (cmdResult) { 428 | console.log("distributionGroup removed OK: " + testGroupEmail); 429 | return psCommandService.execute('removeMsolUser', { 430 | 'UserPrincipalName': testUserEmail 431 | }); 432 | }); 433 | }) 434 | 435 | // handle removeMsolUser result... if ok shutdown... 436 | .then(function (nothing) { 437 | console.log("msolUser removed OK: " + testUserEmail); 438 | cleanupAndShutdown(done, null); 439 | }) 440 | .catch(function (error) { 441 | console.log(error + "\n" + error.stack); 442 | cleanupAndShutdown(done, error); 443 | }); 444 | }; 445 | describe('test PSCommandService w/ o365CommandRegistry', function () { 446 | it('Should test all group and mail contact commands then cleanup with Basic Auth for Exchange', function (done) { 447 | this.timeout(120000); 448 | testRun(done, o365Utils.getO365PSInitCommands( 449 | PATH_TO_DECRYPT_UTIL_SCRIPT, 450 | PATH_TO_ENCRYPTED_CREDENTIALS, 451 | PATH_TO_SECRET_KEY, 452 | 10000, 30000, 60000), o365Utils.getO365PSDestroyCommands()); 453 | }); 454 | it('Should test all group and mail contact commands then cleanup with Certificate based auth', function (done) { 455 | this.timeout(120000); 456 | testRun(done, o365Utils.getO365PSKeyInitCommands( 457 | PATH_TO_DECRYPT_UTIL_SCRIPT, 458 | PATH_TO_ENCRYPTED_CREDENTIALS, 459 | PATH_TO_SECRET_KEY, 460 | PATH_TO_AUTH_CERTIFICATE, 461 | CERTIFICATE_PASSWORD, 462 | APPLICATION_ID, 463 | TENANT_ID, 464 | 10000, 30000, 60000), o365Utils.getO365PSDestroyCommands()); 465 | }); 466 | // The CertificateThumbprint parameter is supported only in Microsoft Windows. 467 | it('Should test all group and mail contact commands then cleanup with Certificate Thumb Print based auth', function (done) { 468 | this.timeout(120000); 469 | testRun(done, o365Utils.getO365PSInitCommands( 470 | PATH_TO_DECRYPT_UTIL_SCRIPT, 471 | PATH_TO_ENCRYPTED_CREDENTIALS, 472 | PATH_TO_SECRET_KEY, 473 | CERTIFICATE_THUMBPRINT, 474 | APPLICATION_ID, 475 | TENANT_ID, 476 | 10000, 30000, 60000), o365Utils.getO365PSDestroyCommands()); 477 | }); 478 | }); -------------------------------------------------------------------------------- /o365Utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * getO365PSInitCommands() 3 | * 4 | * Returns an array of Powershell initialization commands suitable 5 | * for setting up shells spawned with StatefulProcessCommandProxy 6 | * to be able to establish a remote PSSession with o365 7 | * 8 | * @see https://github.com/bitsofinfo/powershell-credential-encryption-tools 9 | * 10 | * This function takes the full path to: 11 | * - decryptUtil.ps1 from the project above 12 | * - path the encrypted credentials file generated with decryptUtil.ps1 13 | * - path to the secret key needed to decrypt the credentials 14 | * 15 | * In addition there are parameter to define the PSSessionOption timeouts 16 | * 17 | * Note this is just an example (which works) however you may want to 18 | * replace this with your own set of init command tailored to your specific 19 | * use-case 20 | * 21 | * @see the getO365PSDestroyCommands() below for the corresponding cleanup 22 | * commands for these init commands 23 | */ 24 | module.exports.getO365PSInitCommands = function (pathToDecryptUtilScript, 25 | pathToCredsFile, 26 | pathToKeyFile, 27 | openTimeout, 28 | operationTimeout, 29 | idleTimeout) { 30 | return [ 31 | // #0 Encoding UTF8 32 | 'chcp 65001', 33 | '$OutputEncoding = [System.Text.Encoding]::GetEncoding(65001)', 34 | 35 | // #1 import some basics 36 | 'Import-Module MSOnline', 37 | 38 | // #2 source the decrypt utils script 39 | // https://github.com/bitsofinfo/powershell-credential-encryption-tools/blob/master/decryptUtil.ps1 40 | ('. ' + pathToDecryptUtilScript), 41 | 42 | // #3 invoke decrypt2PSCredential to get the PSCredential object 43 | // this function is provided by the sourced file above 44 | ('$PSCredential = decrypt2PSCredential ' + pathToCredsFile + ' ' + pathToKeyFile), 45 | 46 | // #4+ establish the session to o365 47 | ('$sessionOpt = New-PSSessionOption -OpenTimeout ' + openTimeout + ' -OperationTimeout ' + operationTimeout + ' -IdleTimeout ' + idleTimeout), 48 | '$session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://outlook.office365.com/powershell-liveid/ -Credential $PSCredential -Authentication Basic -AllowRedirection -SessionOption $sessionOpt', 49 | 50 | // #5 import the relevant cmdlets (TODO: make this configurable) 51 | 'Import-PSSession $session -CommandName *DistributionGroup* -AllowClobber', 52 | 'Import-PSSession $session -CommandName *Contact* -AllowClobber', 53 | 54 | // #6 connect to azure as well 55 | 'Connect-MsolService -Credential $PSCredential', 56 | 57 | // #7 cleanup 58 | 'Remove-Variable -Force -ErrorAction SilentlyContinue $PSCredential' 59 | ]; 60 | }; 61 | 62 | 63 | /** 64 | * Destroy commands that correspond to the session 65 | * established w/ the initCommands above 66 | */ 67 | module.exports.getO365PSDestroyCommands = function () { 68 | return [ 69 | 'Get-PSSession | Remove-PSSession -ErrorAction SilentlyContinue', 70 | 'Remove-PSSession -Session $session', 71 | 'Remove-Module MsOnline' 72 | ]; 73 | }; 74 | 75 | /** 76 | * getO365PSKeyInitCommands() 77 | * 78 | * Returns an array of Powershell initialization commands suitable 79 | * for setting up shells spawned with StatefulProcessCommandProxy 80 | * to be able to establish a remote PSSession with o365 81 | * 82 | * @see https://github.com/bitsofinfo/powershell-credential-encryption-tools 83 | * @see https://docs.microsoft.com/en-us/powershell/module/exchange/connect-exchangeonline?view=exchange-ps 84 | * @see https://adamtheautomator.com/exchange-online-powershell-mfa/#Authenticating_Using_Local_PFX_Certificate 85 | * 86 | * This function takes the full path to: 87 | * - decryptUtil.ps1 from the project above 88 | * - path the encrypted credentials file generated with decryptUtil.ps1 89 | * - path to the secret key needed to decrypt the credentials 90 | * - path to the certificate configured for Exchange app authentication 91 | * - certificate password 92 | * - application id created for the Exchange app integration 93 | * - tenant/organizationId for the Exchange 94 | * - comma separatged list of commands to import, widcard supported. 95 | * Everything is imported if empty 96 | * 97 | * In addition there are parameter to define the PSSessionOption timeouts 98 | * 99 | * Note this is just an example (which works) however you may want to 100 | * replace this with your own set of init command tailored to your specific 101 | * use-case 102 | * 103 | * @see the getO365PSKeyDestroyCommandsgetO365PSKeyDestroyCommands() below 104 | for the corresponding cleanup 105 | * commands for these init commands 106 | */ 107 | module.exports.getO365PSKeyInitCommands = function (pathToDecryptUtilScript, 108 | pathToCredsFile, 109 | pathToKeyFile, 110 | pathToAuthCertificate, 111 | authCertificatePassword, 112 | applicationId, 113 | organizationId, 114 | openTimeout, 115 | operationTimeout, 116 | idleTimeout, 117 | commandsToImport = '') { 118 | 119 | let psCommandsToImport = ''; 120 | if (commandsToImport != '') { 121 | psCommandsToImport = '-CommandName ' + commandsToImport; 122 | } 123 | return [ 124 | // #0 Encoding UTF8 125 | 'chcp 65001', 126 | '$OutputEncoding = [System.Text.Encoding]::GetEncoding(65001)', 127 | 128 | // #1 import some basics 129 | 'Import-Module MSOnline', 130 | 'Import-Module ExchangeOnlineManagement', 131 | 132 | // #2 source the decrypt utils script 133 | // https://github.com/bitsofinfo/powershell-credential-encryption-tools/blob/master/decryptUtil.ps1 134 | ('. ' + pathToDecryptUtilScript), 135 | 136 | // #3 invoke decrypt2PSCredential to get the PSCredential object 137 | // this function is provided by the sourced file above 138 | ('$PSCredential = decrypt2PSCredential ' + pathToCredsFile + ' ' + pathToKeyFile), 139 | 140 | // #4 connect to azure as well 141 | 'Connect-MsolService -Credential $PSCredential', 142 | 143 | // #5 get session options, certificate file and secure password 144 | ('$sessionOpt = New-PSSessionOption -OpenTimeout ' + openTimeout + ' -OperationTimeout ' + operationTimeout + ' -IdleTimeout ' + idleTimeout), 145 | '$CertificateFilePath = (Resolve-Path "' + pathToAuthCertificate + '").Path', 146 | '$CertificatePassword = (ConvertTo-SecureString -String "' + authCertificatePassword + '" -AsPlainText -Force)', 147 | 148 | // #6 connect to exchange 149 | 'Connect-ExchangeOnline -CertificateFilePath $CertificateFilePath -CertificatePassword $CertificatePassword -AppID ' + applicationId + ' -Organization ' + organizationId + ' ' + psCommandsToImport, 150 | 151 | // #7 cleanup 152 | 'Remove-Variable -Force -ErrorAction SilentlyContinue $PSCredential' 153 | ]; 154 | }; 155 | 156 | /** 157 | * Destroy commands that correspond to the session 158 | * established w/ the KeyinitCommands above 159 | */ 160 | module.exports.getO365PSKeyDestroyCommands = function () { 161 | return [ 162 | 'Get-PSSession | Remove-PSSession -ErrorAction SilentlyContinue', 163 | 'Remove-Module MsOnline', 164 | 'Remove-Module ExchangeOnlineManagement', 165 | ]; 166 | }; 167 | 168 | /** 169 | * getO365PSKeyInitCommands() 170 | * 171 | * Returns an array of Powershell initialization commands suitable 172 | * for setting up shells spawned with StatefulProcessCommandProxy 173 | * to be able to establish a remote PSSession with o365 174 | * 175 | * @see https://github.com/bitsofinfo/powershell-credential-encryption-tools 176 | * @see https://docs.microsoft.com/en-us/powershell/module/exchange/connect-exchangeonline?view=exchange-ps 177 | * @see https://adamtheautomator.com/exchange-online-powershell-mfa/#Authenticating_Using_Certificate_Thumbprint 178 | * 179 | * This function takes the full path to: 180 | * - decryptUtil.ps1 from the project above 181 | * - path the encrypted credentials file generated with decryptUtil.ps1 182 | * - path to the secret key needed to decrypt the credentials 183 | * - certificate thumbprint configured for Exchange app authentication 184 | * - application id created for the Exchange app integration 185 | * - tenant/organizationId for the Exchange 186 | * - comma separatged list of commands to import, widcard supported. 187 | * Everything is imported if empty 188 | * 189 | * In addition there are parameter to define the PSSessionOption timeouts 190 | * 191 | * Note this is just an example (which works) however you may want to 192 | * replace this with your own set of init command tailored to your specific 193 | * use-case 194 | * 195 | * @see the getO365PSKeyDestroyCommandsgetO365PSKeyDestroyCommands() below 196 | for the corresponding cleanup 197 | * commands for these init commands 198 | */ 199 | module.exports.getO365PSThumbprintInitCommands = function (pathToDecryptUtilScript, 200 | pathToCredsFile, 201 | pathToKeyFile, 202 | certificateThumbPrint, 203 | applicationId, 204 | organizationId, 205 | openTimeout, 206 | operationTimeout, 207 | idleTimeout, 208 | commandsToImport = '') { 209 | 210 | let psCommandsToImport = ''; 211 | if (commandsToImport != '') { 212 | psCommandsToImport = '-CommandName ' + commandsToImport; 213 | } 214 | return [ 215 | // #0 Encoding UTF8 216 | 'chcp 65001', 217 | '$OutputEncoding = [System.Text.Encoding]::GetEncoding(65001)', 218 | 219 | // #1 import some basics 220 | 'Import-Module MSOnline', 221 | 'Import-Module ExchangeOnlineManagement', 222 | 223 | // #2 source the decrypt utils script 224 | // https://github.com/bitsofinfo/powershell-credential-encryption-tools/blob/master/decryptUtil.ps1 225 | ('. ' + pathToDecryptUtilScript), 226 | 227 | // #3 invoke decrypt2PSCredential to get the PSCredential object 228 | // this function is provided by the sourced file above 229 | ('$PSCredential = decrypt2PSCredential ' + pathToCredsFile + ' ' + pathToKeyFile), 230 | 231 | // #4 connect to azure as well 232 | 'Connect-MsolService -Credential $PSCredential', 233 | 234 | // #5 get session options 235 | ('$sessionOpt = New-PSSessionOption -OpenTimeout ' + openTimeout + ' -OperationTimeout ' + operationTimeout + ' -IdleTimeout ' + idleTimeout), 236 | 237 | // #6 connect to exchange 238 | 'Connect-ExchangeOnline -CertificateThumbPrint ' + certificateThumbPrint + ' -AppID ' + applicationId + ' -Organization ' + organizationId + ' ' + psCommandsToImport, 239 | 240 | // #7 cleanup 241 | 'Remove-Variable -Force -ErrorAction SilentlyContinue $PSCredential' 242 | ]; 243 | }; 244 | 245 | /** 246 | * Destroy commands that correspond to the session 247 | * established w/ the KeyinitCommands above 248 | */ 249 | module.exports.getO365PSThumbprintDestroyCommands = function () { 250 | return [ 251 | 'Get-PSSession | Remove-PSSession -ErrorAction SilentlyContinue', 252 | 'Remove-Module MsOnline', 253 | 'Remove-Module ExchangeOnlineManagement', 254 | ]; 255 | }; 256 | 257 | /** 258 | * Some example blacklisted commands 259 | */ 260 | module.exports.getO365BlacklistedCommands = function () { 261 | return [{ 262 | 'regex': '.*Invoke-Expression.*', 263 | 'flags': 'i' 264 | }, 265 | { 266 | 'regex': '.*ScriptBlock.*', 267 | 'flags': 'i' 268 | }, 269 | { 270 | 'regex': '.*Get-Acl.*', 271 | 'flags': 'i' 272 | }, 273 | { 274 | 'regex': '.*Set-Acl.*', 275 | 'flags': 'i' 276 | }, 277 | { 278 | 'regex': '.*Get-Content.*', 279 | 'flags': 'i' 280 | }, 281 | { 282 | 'regex': '.*-History.*', 283 | 'flags': 'i' 284 | }, 285 | { 286 | 'regex': '.*Out-File.*', 287 | 'flags': 'i' 288 | } 289 | ]; 290 | }; 291 | 292 | /** 293 | * Configuration auto invalidation, checking PSSession availability 294 | * @param checkIntervalMS 295 | */ 296 | module.exports.getO365AutoInvalidationConfig = function (checkIntervalMS) { 297 | return { 298 | 'checkIntervalMS': checkIntervalMS, 299 | 'commands': [ 300 | // no remote pssession established? invalid! 301 | { 302 | 'command': 'Get-PSSession', 303 | 'regexes': { 304 | 'stdout': [{ 305 | 'regex': '.*Opened.*', 306 | 'flags': 'i', 307 | 'invalidOn': 'noMatch' 308 | }] 309 | } 310 | } 311 | ] 312 | }; 313 | }; 314 | 315 | 316 | /** 317 | * Defines a registry of Powershell commands 318 | * that can be injected into the PSCommandService 319 | * instance. 320 | * 321 | * Note these are just some example configurations specifically for a few 322 | * o365 functions and limited arguments for each, (they work) however you may want to 323 | * replace this with your own set of init command tailored to your specific 324 | * use-case 325 | */ 326 | var o365CommandRegistry = { 327 | 328 | /******************************* 329 | * 330 | * o365 Powershell Command registry 331 | * 332 | * argument properties (optional): 333 | * - quoted: true|false, default true 334 | * - valued: true|false, default true 335 | * - default: optional default value (only if valued..) 336 | * 337 | * return properties: 338 | * type: none, text or json are valid values 339 | * 340 | ********************************/ 341 | 342 | /******************************* 343 | * MsolUser 344 | ********************************/ 345 | 346 | 'getMsolUser': { 347 | 'command': 'Get-MsolUser {{{arguments}}} | ConvertTo-Json', 348 | 'arguments': { 349 | 'UserPrincipalName': {} 350 | }, 351 | 'return': { 352 | type: 'json' 353 | } 354 | }, 355 | 356 | 'newMsolUser': { 357 | 'command': 'New-MsolUser {{{arguments}}} | ConvertTo-Json', 358 | 'arguments': { 359 | 'DisplayName': {}, 360 | 'UserPrincipalName': {} 361 | }, 362 | 'return': { 363 | type: 'json' 364 | } 365 | }, 366 | 367 | 'removeMsolUser': { 368 | 'command': 'Remove-MsolUser -Force {{{arguments}}} ', 369 | 'arguments': { 370 | 'UserPrincipalName': {} 371 | }, 372 | 'return': { 373 | type: 'none' 374 | } 375 | }, 376 | 377 | /******************************* 378 | * DistributionGroups 379 | ********************************/ 380 | 381 | 'getDistributionGroup': { 382 | 'command': 'Get-DistributionGroup {{{arguments}}} | ConvertTo-Json', 383 | 'arguments': { 384 | 'Identity': {} 385 | }, 386 | 'return': { 387 | type: 'json' 388 | } 389 | }, 390 | 391 | 'newDistributionGroup': { 392 | 393 | 'command': 'New-DistributionGroup -Confirm:$False {{{arguments}}} | ConvertTo-Json', 394 | 395 | 'arguments': { 396 | 'Name': {}, 397 | 'DisplayName': {}, 398 | 'Alias': {}, 399 | 'PrimarySmtpAddress': {}, 400 | 'Type': { 401 | 'quoted': false, 402 | 'default': 'Security' 403 | }, 404 | 'ManagedBy': {}, 405 | 'Members': {}, // specifying members on create does not seem to work 406 | 'ModerationEnabled': { 407 | 'default': '0', 408 | 'quoted': false 409 | }, 410 | 'MemberDepartRestriction': { 411 | 'default': 'Closed' 412 | }, 413 | 'MemberJoinRestriction': { 414 | 'default': 'Closed' 415 | }, 416 | 'SendModerationNotifications': { 417 | 'default': 'Never', 418 | 'quoted': false 419 | }, 420 | 421 | }, 422 | 'return': { 423 | type: 'json' 424 | } 425 | }, 426 | 427 | 'setDistributionGroup': { 428 | 429 | 'command': 'Set-DistributionGroup -Confirm:$False {{{arguments}}}', 430 | 431 | 'arguments': { 432 | 'Identity': {}, 433 | 'Name': {}, 434 | 'DisplayName': {}, 435 | 'Alias': {}, 436 | 'PrimarySmtpAddress': {}, 437 | 'ManagedBy': {}, 438 | 'Members': {}, 439 | 'MailTip': {}, 440 | 'ModerationEnabled': { 441 | 'default': '0', 442 | 'quoted': false 443 | }, 444 | 'MemberDepartRestriction': { 445 | 'default': 'Closed' 446 | }, 447 | 'MemberJoinRestriction': { 448 | 'default': 'Closed' 449 | }, 450 | 'SendModerationNotifications': { 451 | 'default': 'Never', 452 | 'quoted': false 453 | }, 454 | 'BypassSecurityGroupManagerCheck': { 455 | 'valued': false 456 | } 457 | }, 458 | 'return': { 459 | type: 'none' 460 | } 461 | }, 462 | 463 | 464 | 'removeDistributionGroup': { 465 | 466 | 'command': 'Remove-DistributionGroup {{{arguments}}} -Confirm:$false', 467 | 468 | 'arguments': { 469 | 'Identity': {}, 470 | // needed if invoking as global admin who is not explicitly a group admin.. stupid... yes. 471 | 'BypassSecurityGroupManagerCheck': { 472 | 'valued': false 473 | } 474 | }, 475 | 'return': { 476 | type: 'none' 477 | } 478 | }, 479 | 480 | 481 | 'getDistributionGroupMember': { 482 | 483 | 'command': 'Get-DistributionGroupMember {{{arguments}}} -ResultSize Unlimited | ConvertTo-Json', 484 | 485 | 'arguments': { 486 | 'Identity': {} 487 | }, 488 | 'return': { 489 | type: 'json' 490 | } 491 | }, 492 | 493 | 494 | 'addDistributionGroupMember': { 495 | 496 | 'command': 'Add-DistributionGroupMember {{{arguments}}}', 497 | 498 | 'arguments': { 499 | 'Identity': {}, 500 | 'Member': {}, 501 | // needed if invoking as global admin who is not explicitly a group admin.. stupid... yes. 502 | 'BypassSecurityGroupManagerCheck': { 503 | 'valued': false 504 | } 505 | }, 506 | 'return': { 507 | type: 'none' 508 | } 509 | }, 510 | 511 | // members specified w/ this are a full overwrite.. 512 | 'updateDistributionGroupMembers': { 513 | 514 | 'command': 'Update-DistributionGroupMember -Confirm:$false {{{arguments}}}', 515 | 516 | 'arguments': { 517 | 'Identity': {}, 518 | 'Members': {}, 519 | // needed if invoking as global admin who is not explicitly a group admin.. stupid... yes. 520 | 'BypassSecurityGroupManagerCheck': { 521 | 'valued': false 522 | } 523 | }, 524 | 'return': { 525 | type: 'none' 526 | } 527 | }, 528 | 529 | 'removeDistributionGroupMember': { 530 | 531 | 'command': 'Remove-DistributionGroupMember {{{arguments}}} -Confirm:$false', 532 | 533 | 'arguments': { 534 | 'Identity': {}, 535 | 'Member': {}, 536 | // needed if invoking as global admin who is not explicitly a group admin.. stupid... yes. 537 | 'BypassSecurityGroupManagerCheck': { 538 | 'valued': false 539 | } 540 | }, 541 | 'return': { 542 | type: 'none' 543 | } 544 | }, 545 | 546 | 547 | 548 | 549 | /******************************* 550 | * MailContacts 551 | ********************************/ 552 | 553 | 'getMailContact': { 554 | 'command': 'Get-MailContact {{{arguments}}} | ConvertTo-Json', 555 | 'arguments': { 556 | 'Identity': {} 557 | }, 558 | 'return': { 559 | type: 'json' 560 | } 561 | }, 562 | 563 | 'newMailContact': { 564 | 565 | 'command': 'New-MailContact -Confirm:$False {{{arguments}}} | ConvertTo-Json', 566 | 567 | 'arguments': { 568 | 'Name': {}, 569 | 'ExternalEmailAddress': {} 570 | }, 571 | 572 | 'return': { 573 | type: 'json' 574 | } 575 | }, 576 | 577 | 'setMailContact': { 578 | 579 | 'command': 'Set-MailContact -Confirm:$False {{{arguments}}}', 580 | 581 | 'arguments': { 582 | 'Identity': {}, 583 | 'Name': {}, 584 | 'DisplayName': {}, 585 | 'ExternalEmailAddress': {} 586 | }, 587 | 588 | 'return': { 589 | type: 'none' 590 | } 591 | }, 592 | 593 | 594 | 'removeMailContact': { 595 | 596 | 'command': 'Remove-MailContact {{{arguments}}} -Confirm:$false', 597 | 598 | 'arguments': { 599 | 'Identity': {} 600 | }, 601 | 602 | 'return': { 603 | type: 'none' 604 | } 605 | }, 606 | getStatus: { 607 | command: 'Get-ConnectionInformation | ConvertTo-Json', 608 | return: { 609 | type: 'json' 610 | } 611 | }, 612 | }; 613 | 614 | module.exports.o365CommandRegistry = o365CommandRegistry; 615 | 616 | /** 617 | * Some example whitelisted commands 618 | * (only permit) what is in the registry 619 | */ 620 | module.exports.getO365WhitelistedCommands = function () { 621 | var whitelist = []; 622 | for (var cmdName in o365CommandRegistry) { 623 | var config = o365CommandRegistry[cmdName]; 624 | var commandStart = config.command.substring(0, config.command.indexOf(' ')).trim(); 625 | whitelist.push({ 626 | 'regex': '^' + commandStart + '\\s+.*', 627 | 'flags': 'i' 628 | }); 629 | } 630 | return whitelist; 631 | }; -------------------------------------------------------------------------------- /test/unit.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var o365Utils = require("../o365Utils"); 3 | var PSCommandService = require("../psCommandService"); 4 | 5 | /** 6 | * IMPORTANT! 7 | * To run this test, you need to configure 8 | * the following 4 variables! 9 | * 10 | * The credentials you are using to access o365 should 11 | * be for a user that is setup as follows @: 12 | * https://bitsofinfo.wordpress.com/2015/01/06/configuring-powershell-for-azure-ad-and-o365-exchange-management/ 13 | * 14 | * @see https://github.com/bitsofinfo/powershell-credential-encryption-tools 15 | */ 16 | var O365_TENANT_DOMAIN_NAME = 17 | process.env.O365_TENANT_DOMAIN_NAME || "somedomain.com"; 18 | 19 | /** 20 | * Following variables needed to test Certificate based connection to Exchange server 21 | * 22 | * @see https: //adamtheautomator.com/exchange-online-powershell-mfa/ 23 | * for setup instructions 24 | */ 25 | var CERTIFICATE = process.env.CERTIFICATE || "xxxxxxxxxx"; 26 | var CERTIFICATE_PASSWORD = process.env.CERTIFICATE_PASSWORD || "xxxxxxxxxx"; 27 | var APPLICATION_ID = 28 | process.env.APPLICATION_ID || "00000000-00000000-00000000-00000000"; 29 | var TENANT = process.env.TENANT || "your.exhange.domain.name"; 30 | 31 | const initCommands = [ 32 | "$OutputEncoding = [System.Text.Encoding]::GetEncoding(65001)", 33 | '$ErrorView = "NormalView"', // works for powershell 7.1 34 | '$PSStyle.OutputRendering = "PlainText"', // works for powershell 7.2 and above 35 | '$PSDefaultParameterValues["*:Encoding"] = "utf8"', 36 | ]; 37 | 38 | const initExchangeCommands = [ 39 | "$OutputEncoding = [System.Text.Encoding]::GetEncoding(65001)", 40 | '$ErrorView = "NormalView"', // works for powershell 7.1 41 | '$PSStyle.OutputRendering = "PlainText"', // works for powershell 7.2 and above 42 | '$PSDefaultParameterValues["*:Encoding"] = "utf8"', 43 | 44 | // #1 import some basics 45 | "Import-Module ExchangeOnlineManagement", 46 | // #2 create certificate password 47 | `$CertificatePassword = (ConvertTo-SecureString -String "${CERTIFICATE_PASSWORD}" -AsPlainText -Force)`, 48 | // #3 Import certificate from base64 string 49 | `$Certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2([Convert]::FromBase64String("${CERTIFICATE}"), $CertificatePassword, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]"PersistKeySet")`, 50 | // #4 connect to exchange 51 | `Connect-ExchangeOnline -ShowBanner:$false -ShowProgress:$false -Certificate $Certificate -CertificatePassword $CertificatePassword -AppID ${APPLICATION_ID} -Organization ${TENANT}`, 52 | ]; 53 | 54 | const preDestroyCommands = [ 55 | "Disconnect-ExchangeOnline -Confirm:$false", 56 | "Remove-Module ExchangeOnlineManagement -Force", 57 | ]; 58 | 59 | const myLogFunction = (severity, origin, message) => { 60 | console.log(severity.toUpperCase() + " " + origin + " " + message); 61 | }; 62 | const logFunction = (severity, origin, msg) => { 63 | if (origin != "Pool") { 64 | console.log(severity.toUpperCase() + " " + origin + " " + msg); 65 | } 66 | }; 67 | 68 | const commandRegistry = { 69 | setClipboard: { 70 | command: "Set-Clipboard {{{arguments}}}", 71 | arguments: { 72 | 'Value': { 73 | quoted: false, 74 | }, 75 | }, 76 | return: { 77 | type: 'none' 78 | }, 79 | }, 80 | getClipboard: { 81 | command: "Get-Clipboard", 82 | arguments: {}, 83 | return: { 84 | type: "text", 85 | }, 86 | }, 87 | setContent: { 88 | command: "Set-Content {{{arguments}}}", 89 | arguments: { 90 | 'Path': {}, 91 | 'Value': {}, 92 | 'Filter': {}, 93 | }, 94 | }, 95 | getContent: { 96 | command: "Get-Content {{{arguments}}}", 97 | arguments: { 98 | 'Path': {}, 99 | 'Filter': { 100 | empty: true, 101 | }, 102 | }, 103 | return: { 104 | type: "text", 105 | }, 106 | }, 107 | removeItem: { 108 | command: "Remove-Item {{{arguments}}}", 109 | arguments: { 110 | 'Path': {}, 111 | }, 112 | }, 113 | }; 114 | 115 | 116 | const StatefulProcessCommandProxy = require("stateful-process-command-proxy"); 117 | 118 | const testRun = async (done, initCommands, preDestroyCommands) => { 119 | const statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 120 | name: "o365 RemotePSSession powershell pool", 121 | max: 1, 122 | min: 1, 123 | idleTimeoutMS: 30000, 124 | 125 | logFunction: logFunction, 126 | 127 | processCommand: "pwsh", 128 | processArgs: ["-Command", "-"], 129 | 130 | processRetainMaxCmdHistory: 30, 131 | processInvalidateOnRegex: { 132 | any: [ 133 | { 134 | regex: ".*nomatch.*", 135 | flags: "i", 136 | }, 137 | ], 138 | stdout: [ 139 | { 140 | regex: ".*nomatch.*", 141 | }, 142 | ], 143 | stderr: [ 144 | { 145 | regex: ".*nomatch.*", 146 | }, 147 | ], 148 | }, 149 | processCwd: null, 150 | processEnvMap: null, 151 | processUid: null, 152 | processGid: null, 153 | 154 | initCommands: initCommands, 155 | 156 | validateFunction: (processProxy) => processProxy.isValid(), 157 | 158 | preDestroyCommands: preDestroyCommands, 159 | 160 | processCmdBlacklistRegex: o365Utils.getO365BlacklistedCommands(), 161 | 162 | processCmdWhitelistRegex: o365Utils.getO365WhitelistedCommands(), 163 | 164 | autoInvalidationConfig: o365Utils.getO365AutoInvalidationConfig(30000), 165 | }); 166 | 167 | const psCommandService = new PSCommandService( 168 | statefulProcessCommandProxy, 169 | o365Utils.o365CommandRegistry, 170 | myLogFunction 171 | ); 172 | 173 | const statusResponse = await psCommandService.execute("getStatus", {}); 174 | if (statusResponse.stderr == '' && statusResponse.stdout == '') { 175 | console.log('Skipping test as getStatus command failed'); 176 | statefulProcessCommandProxy.shutdown(); 177 | done(); 178 | } 179 | 180 | const random = 181 | "unitTest" + 182 | Math.abs(Math.floor(Math.random() * (1000 - 99999 + 1) + 1000)); 183 | 184 | const testMailContactName = "amailContact-" + random; 185 | const testMailContactEmail = 186 | testMailContactName + "@" + O365_TENANT_DOMAIN_NAME; 187 | 188 | const testOwnerGroupName = "owneragroup-" + random; 189 | const testOwnerGroupEmail = 190 | testOwnerGroupName + "@" + O365_TENANT_DOMAIN_NAME; 191 | 192 | const testGroupName = "agroup-" + random; 193 | const testGroupEmail = testGroupName + "@" + O365_TENANT_DOMAIN_NAME; 194 | 195 | const testGroupName2 = "agroup-2" + random; 196 | const testGroupEmail2 = testGroupName2 + "@" + O365_TENANT_DOMAIN_NAME; 197 | 198 | const cleanupAndShutdown = async (done, error) => { 199 | await psCommandService.execute("removeDistributionGroup", { 200 | Identity: testOwnerGroupEmail, 201 | }); 202 | await psCommandService.execute("removeDistributionGroup", { 203 | Identity: testGroupEmail, 204 | }); 205 | await psCommandService.execute("removeDistributionGroup", { 206 | Identity: testGroupEmail2, 207 | }); 208 | await psCommandService.execute("removeMailContact", { 209 | Identity: testMailContactEmail, 210 | }); 211 | 212 | setTimeout(() => { 213 | statefulProcessCommandProxy.shutdown(); 214 | }, 5000); 215 | 216 | setTimeout(() => { 217 | if (error) { 218 | done(error); 219 | } else { 220 | done(); 221 | } 222 | }, 10000); 223 | 224 | if (error) { 225 | throw error; 226 | } 227 | }; 228 | 229 | try { 230 | const ownerGroupCreateResult = await psCommandService.execute( 231 | "newDistributionGroup", 232 | { 233 | Name: testOwnerGroupName, 234 | DisplayName: testOwnerGroupName, 235 | PrimarySmtpAddress: testOwnerGroupEmail, 236 | } 237 | ); 238 | assert.equal(ownerGroupCreateResult.stderr, ""); 239 | 240 | const testGroupCreateResult = await psCommandService.execute( 241 | "newDistributionGroup", 242 | { 243 | Name: testGroupName, 244 | DisplayName: testGroupName, 245 | PrimarySmtpAddress: testGroupEmail, 246 | ManagedBy: testOwnerGroupEmail, 247 | } 248 | ); 249 | 250 | assert.equal(testGroupCreateResult.stderr, ""); 251 | assert.equal(testGroupCreateResult.commandName, "newDistributionGroup"); 252 | 253 | const distributionGroup = JSON.parse(testGroupCreateResult.stdout); 254 | try { 255 | assert.equal(testGroupEmail, distributionGroup.PrimarySmtpAddress); 256 | } catch (e) { 257 | cleanupAndShutdown(done, e); 258 | } 259 | console.log( 260 | "distributionGroup created OK: " + distributionGroup.PrimarySmtpAddress 261 | ); 262 | 263 | const testGroup2CreateResult = await psCommandService.execute( 264 | "newDistributionGroup", 265 | { 266 | Name: testGroupName2, 267 | DisplayName: testGroupName2, 268 | PrimarySmtpAddress: testGroupEmail2, 269 | ManagedBy: testOwnerGroupEmail, 270 | } 271 | ); 272 | 273 | assert.equal(testGroup2CreateResult.stderr, ""); 274 | assert.equal(testGroup2CreateResult.commandName, "newDistributionGroup"); 275 | 276 | const distributionGroup2 = JSON.parse(testGroup2CreateResult.stdout); 277 | try { 278 | assert.equal(testGroupEmail2, distributionGroup2.PrimarySmtpAddress); 279 | } catch (e) { 280 | cleanupAndShutdown(done, e); 281 | } 282 | console.log( 283 | "distributionGroup created OK: " + distributionGroup2.PrimarySmtpAddress 284 | ); 285 | 286 | await psCommandService.executeAll([ 287 | { 288 | commandName: "addDistributionGroupMember", 289 | argMap: { 290 | Identity: testGroupEmail, 291 | Member: testGroupEmail2, 292 | BypassSecurityGroupManagerCheck: null, 293 | }, 294 | }, 295 | { 296 | commandName: "addDistributionGroupMember", 297 | argMap: { 298 | Identity: testGroupEmail, 299 | Member: testOwnerGroupEmail, 300 | BypassSecurityGroupManagerCheck: null, 301 | }, 302 | }, 303 | ]); 304 | console.log("distributionGroupMembers added OK"); 305 | 306 | const groupMembersResult = await psCommandService.execute( 307 | "getDistributionGroupMember", 308 | { 309 | Identity: testGroupEmail, 310 | } 311 | ); 312 | 313 | assert.equal(groupMembersResult.stderr, ""); 314 | assert.equal(groupMembersResult.commandName, "getDistributionGroupMember"); 315 | 316 | var members = JSON.parse(groupMembersResult.stdout); 317 | try { 318 | assert.equal(members.length, 2); 319 | } catch (e) { 320 | cleanupAndShutdown(done, e); 321 | } 322 | console.log("distributionGroup members fetched OK: " + members.length); 323 | const removeResult = await psCommandService.execute( 324 | "removeDistributionGroupMember", 325 | { 326 | Identity: testGroupEmail, 327 | Member: testGroupEmail2, 328 | } 329 | ); 330 | assert.equal(removeResult.stderr, ""); 331 | assert.equal(removeResult.commandName, "removeDistributionGroupMember"); 332 | 333 | console.log(`distributionGroupMember (${testGroupEmail2}) removed OK`); 334 | 335 | const refetchGroupMembersResult = await psCommandService.execute( 336 | "getDistributionGroupMember", 337 | { 338 | Identity: testGroupEmail, 339 | } 340 | ); 341 | var members = JSON.parse("[" + refetchGroupMembersResult.stdout + "]"); 342 | try { 343 | assert.equal(members.length, 1); 344 | assert.equal(members[0].PrimarySmtpAddress, testOwnerGroupEmail); 345 | } catch (e) { 346 | return cleanupAndShutdown(done, e); 347 | } 348 | console.log( 349 | "getDistributionGroupMember fetched OK: only owner group remains " + 350 | members.length 351 | ); 352 | const contactResult = await psCommandService.execute("newMailContact", { 353 | Name: testMailContactName, 354 | ExternalEmailAddress: testMailContactEmail, 355 | }); 356 | 357 | assert.equal(contactResult.stderr, ""); 358 | assert.equal(contactResult.commandName, "newMailContact"); 359 | 360 | console.log("newMailContact added OK: " + testMailContactEmail); 361 | const getContactResult = await psCommandService.execute("getMailContact", { 362 | Identity: testMailContactEmail, 363 | }); 364 | 365 | var contact = JSON.parse(getContactResult.stdout); 366 | try { 367 | assert.equal(testMailContactEmail, contact.PrimarySmtpAddress); 368 | } catch (e) { 369 | cleanupAndShutdown(done, e); 370 | } 371 | console.log("getMailContact fetched OK: " + testMailContactEmail); 372 | await psCommandService.execute("addDistributionGroupMember", { 373 | Identity: testGroupEmail, 374 | Member: testMailContactEmail, 375 | }); 376 | 377 | console.log( 378 | "addDistributionGroupMember mailContact added OK: " + testMailContactEmail 379 | ); 380 | const getGroupMembersResult = await psCommandService.execute( 381 | "getDistributionGroupMember", 382 | { 383 | Identity: testGroupEmail, 384 | } 385 | ); 386 | 387 | var members = JSON.parse(getGroupMembersResult.stdout); 388 | try { 389 | assert.equal(members.length, 2); 390 | } catch (e) { 391 | cleanupAndShutdown(done, e); 392 | } 393 | console.log( 394 | "getDistributionGroupMember fetched OK: one mail contact and one group exist " + 395 | members.length 396 | ); 397 | await psCommandService.execute("removeDistributionGroup", { 398 | Identity: testGroupEmail, 399 | }); 400 | 401 | console.log("distributionGroup removed OK: " + testGroupEmail); 402 | 403 | done(); 404 | } catch (error) { 405 | cleanupAndShutdown(done, error); 406 | } 407 | }; 408 | 409 | describe("test PSCommandService w/ o365CommandRegistry", function () { 410 | it("Should test all group and mail contact commands then cleanup with Certificate based auth", function (done) { 411 | this.timeout(120000); 412 | testRun(done, initExchangeCommands, preDestroyCommands); 413 | }); 414 | it("Should test whitelist", async function () { 415 | this.timeout(10000); 416 | const statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 417 | name: "Powershell pool", 418 | max: 1, 419 | min: 1, 420 | idleTimeoutMS: 30000, 421 | 422 | logFunction: logFunction, 423 | processCommand: "pwsh", 424 | processArgs: ["-Command", "-"], 425 | processRetainMaxCmdHistory: 30, 426 | processCwd: null, 427 | processEnvMap: null, 428 | processUid: null, 429 | processGid: null, 430 | initCommands: initCommands, 431 | processCmdWhitelistRegex: [{ regex: '^Set-Clipboard\\s+.*', flags: 'i' }], 432 | validateFunction: (processProxy) => processProxy.isValid(), 433 | }); 434 | 435 | const psCommandService = new PSCommandService( 436 | statefulProcessCommandProxy, 437 | commandRegistry, 438 | myLogFunction 439 | ); 440 | 441 | try { 442 | const value = "'test clipboard value'"; 443 | const setResult = await psCommandService.execute("setClipboard", { 444 | Value: value, 445 | }); 446 | assert.equal(setResult.stderr, ""); 447 | try { 448 | await psCommandService.execute("getClipboard", {}); 449 | } catch (e) { 450 | assert.match(e.message, /Command cannot be executed it does not match our set of whitelisted commands/); 451 | } 452 | 453 | setTimeout(() => { 454 | statefulProcessCommandProxy.shutdown(); 455 | }, 5000); 456 | 457 | return; 458 | } catch (e) { 459 | setTimeout(() => { 460 | statefulProcessCommandProxy.shutdown(); 461 | }, 5000); 462 | throw e; 463 | } 464 | }); 465 | it("Should test blacklist", async function () { 466 | this.timeout(10000); 467 | const statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 468 | name: "Powershell pool", 469 | max: 1, 470 | min: 1, 471 | idleTimeoutMS: 30000, 472 | 473 | logFunction: logFunction, 474 | processCommand: "pwsh", 475 | processArgs: ["-Command", "-"], 476 | processRetainMaxCmdHistory: 30, 477 | processCwd: null, 478 | processEnvMap: null, 479 | processUid: null, 480 | processGid: null, 481 | initCommands: initCommands, 482 | processCmdBlacklistRegex: o365Utils.getO365BlacklistedCommands(), 483 | validateFunction: (processProxy) => processProxy.isValid(), 484 | }); 485 | const extendedCommandRegistry = {...commandRegistry, ...{ 486 | getHistory: { 487 | command: "Get-History", 488 | arguments: {}, 489 | return: { 490 | type: "text", 491 | }, 492 | }, 493 | }}; 494 | 495 | const psCommandService = new PSCommandService( 496 | statefulProcessCommandProxy, 497 | extendedCommandRegistry, 498 | myLogFunction 499 | ); 500 | 501 | const allowResult = await psCommandService.execute("getClipboard", {}); 502 | assert.equal(allowResult.stderr, ""); 503 | assert.equal(allowResult.stdout, ""); 504 | try { 505 | await psCommandService.execute("getHistory", {}); 506 | } catch (e) { 507 | assert.match(e.message, /Command cannot be executed as it matches a blacklist regex pattern/); 508 | } 509 | 510 | try { 511 | setTimeout(() => { 512 | statefulProcessCommandProxy.shutdown(); 513 | }, 5000); 514 | 515 | return; 516 | } catch (e) { 517 | setTimeout(() => { 518 | statefulProcessCommandProxy.shutdown(); 519 | }, 5000); 520 | throw e; 521 | } 522 | }); 523 | it("Should test validation", async function () { 524 | this.timeout(10000); 525 | const statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 526 | name: "Powershell pool", 527 | max: 1, 528 | min: 1, 529 | idleTimeoutMS: 30000, 530 | 531 | logFunction: logFunction, 532 | processCommand: "pwsh", 533 | processArgs: ["-Command", "-"], 534 | processRetainMaxCmdHistory: 30, 535 | processCwd: null, 536 | processEnvMap: null, 537 | processUid: null, 538 | processGid: null, 539 | initCommands: initCommands, 540 | validateFunction: (processProxy) => processProxy.isValid(), 541 | }); 542 | 543 | const psCommandService = new PSCommandService( 544 | statefulProcessCommandProxy, 545 | commandRegistry, 546 | myLogFunction 547 | ); 548 | 549 | const assertClipboard = async (value) => { 550 | const setResult = await psCommandService.execute("setClipboard", { 551 | Value: value, 552 | }); 553 | assert.equal(setResult.stderr, ""); 554 | const getResult = await psCommandService.execute("getClipboard", {}); 555 | assert.equal(getResult.stderr, ""); 556 | return getResult; 557 | } 558 | 559 | try { 560 | // non quoted value 561 | var value = "plain text in clipboard"; 562 | var setResult = await psCommandService.execute("setClipboard", { 563 | Value: value, 564 | }); 565 | assert.equal(setResult.stdout, ""); 566 | assert.match(setResult.stderr, /A positional parameter cannot be found that accepts argument/); 567 | await psCommandService.execute("getClipboard", {}); 568 | // simple multi param value 569 | var res = await assertClipboard('@{add="test","test2";remove="test3","test4"}'); 570 | assert.equal(res.stdout, "System.Collections.Hashtable"); 571 | // multi params value with unsupported keys 572 | value = '@{add="test","test2";remove="test3","test4";fake="test5","test6"}'; 573 | setResult = await psCommandService.execute("setClipboard", { 574 | Value: value, 575 | }); 576 | assert.equal(setResult.command, 'Set-Clipboard -Value @{Add="test","test2"; Remove="test3","test4"} '); 577 | assert.equal(setResult.stderr, ""); 578 | getResult = await psCommandService.execute("getClipboard", {}); 579 | assert.equal(getResult.stderr, ""); 580 | assert.equal(getResult.stdout, "System.Collections.Hashtable"); 581 | // sample quoted test 582 | res = await assertClipboard("'sample text'"); 583 | assert.equal(res.stdout, "sample text"); 584 | 585 | // espcaped quotes 586 | value = "'; Get-ChildItem C:\; '"; 587 | setResult = await psCommandService.execute("setClipboard", { 588 | Value: value, 589 | }); 590 | assert.equal(setResult.stderr, ""); 591 | getResult = await psCommandService.execute("getClipboard", {}); 592 | assert.equal(getResult.stdout, "`; Get-ChildItem C:`;"); 593 | // reserved variable 594 | var res = await assertClipboard('$true'); 595 | assert.equal(res.stdout, "True"); 596 | 597 | setTimeout(() => { 598 | statefulProcessCommandProxy.shutdown(); 599 | }, 5000); 600 | 601 | return; 602 | } catch (e) { 603 | setTimeout(() => { 604 | statefulProcessCommandProxy.shutdown(); 605 | }, 5000); 606 | throw e; 607 | } 608 | }); 609 | it("Should test value bleeding", async function () { 610 | this.timeout(10000); 611 | const statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 612 | name: "Powershell pool", 613 | max: 1, 614 | min: 1, 615 | idleTimeoutMS: 30000, 616 | 617 | logFunction: logFunction, 618 | processCommand: "pwsh", 619 | processArgs: ["-Command", "-"], 620 | processRetainMaxCmdHistory: 30, 621 | processCwd: null, 622 | processEnvMap: null, 623 | processUid: null, 624 | processGid: null, 625 | initCommands: initCommands, 626 | validateFunction: (processProxy) => processProxy.isValid(), 627 | }); 628 | 629 | const psCommandService = new PSCommandService( 630 | statefulProcessCommandProxy, 631 | commandRegistry, 632 | myLogFunction 633 | ); 634 | try { 635 | const newResult = await psCommandService.execute("setContent", { 636 | Path: "./test.txt", 637 | Value: "Test", 638 | Filter: "" 639 | }); 640 | assert.equal(newResult.command.trim(), "Set-Content -Path './test.txt' -Value 'Test'"); 641 | assert.equal(newResult.stderr, ""); 642 | const getResult = await psCommandService.execute("getContent", { 643 | Path: "./test.txt", 644 | }); 645 | assert.equal(getResult.stderr, ""); 646 | assert.equal(getResult.stdout, "Test"); 647 | } catch (e) { 648 | assert.fail(e); 649 | } finally { 650 | await psCommandService.execute("removeItem", { 651 | Path: "./test.txt", 652 | }); 653 | setTimeout(() => { 654 | statefulProcessCommandProxy.shutdown(); 655 | }, 5000); 656 | } 657 | }); 658 | it("Should test empty value support", async function () { 659 | this.timeout(10000); 660 | const statefulProcessCommandProxy = new StatefulProcessCommandProxy({ 661 | name: "Powershell pool", 662 | max: 1, 663 | min: 1, 664 | idleTimeoutMS: 30000, 665 | 666 | logFunction: logFunction, 667 | processCommand: "pwsh", 668 | processArgs: ["-Command", "-"], 669 | processRetainMaxCmdHistory: 30, 670 | processCwd: null, 671 | processEnvMap: null, 672 | processUid: null, 673 | processGid: null, 674 | initCommands: initCommands, 675 | validateFunction: (processProxy) => processProxy.isValid(), 676 | }); 677 | 678 | const psCommandService = new PSCommandService( 679 | statefulProcessCommandProxy, 680 | commandRegistry, 681 | myLogFunction 682 | ); 683 | try { 684 | const newResult = await psCommandService.execute("setContent", { 685 | Path: "./test.txt", 686 | Value: "Test", 687 | Filter: "" 688 | }); 689 | assert.equal(newResult.command.trim(), "Set-Content -Path './test.txt' -Value 'Test'"); 690 | assert.equal(newResult.stderr, ""); 691 | const getResult = await psCommandService.execute("getContent", { 692 | Path: "./test.txt", 693 | Filter: "" 694 | }); 695 | assert.equal(getResult.command.trim(), "Get-Content -Path './test.txt' -Filter ''"); 696 | assert.equal(getResult.stderr, ""); 697 | assert.equal(getResult.stdout, "Test"); 698 | } catch (e) { 699 | assert.fail(e); 700 | } finally { 701 | await psCommandService.execute("removeItem", { 702 | Path: "./test.txt", 703 | }); 704 | setTimeout(() => { 705 | statefulProcessCommandProxy.shutdown(); 706 | }, 5000); 707 | } 708 | }); 709 | }); 710 | --------------------------------------------------------------------------------