├── 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 | [](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 | 
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 |
--------------------------------------------------------------------------------