From 07d71a4eb4381ff085ce58cf23932df630db3614 Mon Sep 17 00:00:00 2001 From: jace-roell Date: Mon, 12 Aug 2024 13:25:34 -0400 Subject: [PATCH 1/4] ssh keypassphrase port from v3 Signed-off-by: jace-roell --- .../issue/ssh/Ssh.handler.unit.test.ts | 130 +++++++++- .../Ssh.handler.unit.test.ts.snap | 10 + packages/imperative/CHANGELOG.md | 4 + .../ConnectionPropsForSessCfg.unit.test.ts | 105 +++++++- .../src/session/ConnectionPropsForSessCfg.ts | 42 +++- .../session/doc/IOptionsForAddConnProps.ts | 15 +- .../session/doc/IOverridePromptConnProps.ts | 10 +- .../rest/src/session/doc/IPropsToPromptFor.ts | 19 ++ packages/zosuss/CHANGELOG.md | 5 + .../__unit__/SshBaseHandler.unit.test.ts | 231 ++++++++++++++++++ .../SshBaseHandler.unit.test.ts.snap | 17 ++ packages/zosuss/src/SshBaseHandler.ts | 129 ++++++++-- 12 files changed, 659 insertions(+), 58 deletions(-) create mode 100644 packages/imperative/src/rest/src/session/doc/IPropsToPromptFor.ts create mode 100644 packages/zosuss/__tests__/__unit__/SshBaseHandler.unit.test.ts create mode 100644 packages/zosuss/__tests__/__unit__/__snapshots__/SshBaseHandler.unit.test.ts.snap diff --git a/packages/cli/__tests__/zosuss/__unit__/issue/ssh/Ssh.handler.unit.test.ts b/packages/cli/__tests__/zosuss/__unit__/issue/ssh/Ssh.handler.unit.test.ts index 6fe5fc3b96..58c1fedba0 100644 --- a/packages/cli/__tests__/zosuss/__unit__/issue/ssh/Ssh.handler.unit.test.ts +++ b/packages/cli/__tests__/zosuss/__unit__/issue/ssh/Ssh.handler.unit.test.ts @@ -11,8 +11,8 @@ jest.mock("../../../../../../zosuss/lib/Shell"); -import { IHandlerParameters, IProfile, CommandProfiles } from "@zowe/imperative"; -import * as SshHandler from "../../../../../src/zosuss/issue/ssh/Ssh.handler"; +import { IHandlerParameters, IProfile, CommandProfiles, ConnectionPropsForSessCfg } from "@zowe/imperative"; +import SshHandler from "../../../../../src/zosuss/issue/ssh/Ssh.handler"; import * as SshDefinition from "../../../../../src/zosuss/issue/ssh/Ssh.definition"; import { Shell } from "@zowe/zos-uss-for-zowe-sdk"; import { mockHandlerParameters } from "@zowe/cli-test-utils"; @@ -33,6 +33,20 @@ const UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY = { user: "someone", privateKey: normalize(join(__dirname, "..", "..", "..", "..", "..", "..", "zosuss", "__tests__", "__unit__", "__resources__", "fake_id_rsa")) }; +const UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE = { + host: "somewhere.com", + port: "22", + user: "someone", + privateKey: normalize(join(__dirname, "..", "..", "..", "..", "..", "..", "zosuss", "__tests__", "__unit__", "__resources__", "fake_id_rsa")), + keyPassPhrase: "dummyPassPhrase123" +}; +const UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER = { + host: "somewhere.com", + port: "22", + privateKey: normalize(join(__dirname, "..", "..", "..", "..", "..", "..", "zosuss", "__tests__", "__unit__", "__resources__", "fake_id_rsa")), + keyPassPhrase: "dummyPassPhrase123" +}; + // A mocked profile map with ssh profile const UNIT_TEST_PROFILE_MAP = new Map(); @@ -53,7 +67,26 @@ UNIT_TEST_PROFILE_MAP_PRIVATE_KEY.set( ...UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY }] ); +const UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE = new Map(); +UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE.set( + "ssh", [{ + name: "ssh", + type: "ssh", + ...UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE + }] +); +const UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER = new Map(); +UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE.set( + "ssh", [{ + name: "ssh", + type: "ssh", + ...UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER + }] +); + const UNIT_TEST_PROFILES_SSH_PRIVATE_KEY = new CommandProfiles(UNIT_TEST_PROFILE_MAP_PRIVATE_KEY); +const UNIT_TEST_PROFILES_SSH_PRIVATE_KEY_WITH_PASSPHRASE = new CommandProfiles(UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE); +const UNIT_TEST_PROFILES_SSH_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER = new CommandProfiles(UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER); // Mocked parameters for the unit tests const DEFAULT_PARAMETERS: IHandlerParameters = mockHandlerParameters({ @@ -70,6 +103,19 @@ const DEFAULT_PARAMETERS_PRIVATE_KEY: IHandlerParameters = mockHandlerParameters profiles: UNIT_TEST_PROFILES_SSH_PRIVATE_KEY }); +const DEFAULT_PARAMETERS_KEY_PASSPHRASE: IHandlerParameters = mockHandlerParameters({ + arguments: UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE, + positionals: ["zos-uss", "issue", "ssh"], + definition: SshDefinition.SshDefinition, + profiles: UNIT_TEST_PROFILES_SSH_PRIVATE_KEY_WITH_PASSPHRASE, +}); +const DEFAULT_PARAMETERS_KEY_PASSPHRASE_NO_USER: IHandlerParameters = mockHandlerParameters({ + arguments: UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER, + positionals: ["zos-uss", "issue", "ssh"], + definition: SshDefinition.SshDefinition, + profiles: UNIT_TEST_PROFILES_SSH_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER, +}); + const testOutput = "TEST OUTPUT"; describe("issue ssh handler tests", () => { @@ -82,7 +128,7 @@ describe("issue ssh handler tests", () => { Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { stdoutHandler(testOutput); }); - const handler = new SshHandler.default(); + const handler = new SshHandler(); const params = Object.assign({}, ...[DEFAULT_PARAMETERS]); params.arguments.command = "pwd"; await handler.process(params); @@ -90,23 +136,94 @@ describe("issue ssh handler tests", () => { expect(testOutput).toMatchSnapshot(); }); + it("should be able to get stdout with private key and key passphrase", async () => { + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new SshHandler(); + const params = Object.assign({}, ...[DEFAULT_PARAMETERS_KEY_PASSPHRASE]); + params.arguments.command = "echo test"; + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); + it("should prompt user for keyPassphrase if none is stored and privateKey requires one", async () => { + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new SshHandler(); + const params = Object.assign({}, ...[DEFAULT_PARAMETERS_KEY_PASSPHRASE]); + params.arguments.command = "echo test"; + jest.spyOn(handler,"processCmd").mockImplementationOnce(() => {throw new Error("but no passphrase given");}); + jest.spyOn(ConnectionPropsForSessCfg as any,"getValuesBack").mockReturnValue(() => ({ + keyPassphrase: "validPassword" + })); + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); + it("should reprompt user for keyPassphrase up to 3 times if stored passphrase failed", async () => { + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new SshHandler(); + const params = Object.assign({}, ...[DEFAULT_PARAMETERS_KEY_PASSPHRASE]); + params.arguments.command = "echo test"; + jest.spyOn(handler,"processCmd").mockImplementationOnce(() => {throw new Error("bad passphrase?");}); + jest.spyOn(ConnectionPropsForSessCfg as any,"getValuesBack").mockReturnValue(() => ({ + keyPassphrase: "validPassword" + })); + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); + it("should fail if user fails to enter incorrect key passphrase in 3 attempts", async () => { + const testOutput = "Maximum retry attempts reached. Authentication failed."; + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new SshHandler(); + const params = { ...DEFAULT_PARAMETERS_KEY_PASSPHRASE }; + params.arguments.command = "echo test"; + jest.spyOn(handler, "processCmd").mockImplementation(() => { + throw new Error("bad passphrase?"); + }); + await expect(handler.process(params)).rejects.toThrow("Maximum retry attempts reached. Authentication failed."); + expect(handler.processCmd).toHaveBeenCalledTimes(4); + expect(testOutput).toMatchSnapshot(); + }); + it("should prompt for user and keyPassphrase if neither is stored", async () => { + const testOutput = "test"; + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new SshHandler(); + const params = { ...DEFAULT_PARAMETERS_KEY_PASSPHRASE_NO_USER }; + params.arguments.command = "echo test"; + jest.spyOn(ConnectionPropsForSessCfg as any,"getValuesBack").mockReturnValue(() => ({ + user: "someone", + keyPassphrase: "validPassword" + })); + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); it("should be able to get stdout with privateKey", async () => { Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { stdoutHandler(testOutput); }); - const handler = new SshHandler.default(); + const handler = new SshHandler(); const params = Object.assign({}, ...[DEFAULT_PARAMETERS_PRIVATE_KEY]); params.arguments.command = "pwd"; await handler.process(params); expect(Shell.executeSsh).toHaveBeenCalledTimes(1); expect(testOutput).toMatchSnapshot(); }); - it("should be able to get stdout with cwd option", async () => { Shell.executeSshCwd = jest.fn(async (session, command, cwd, stdoutHandler) => { stdoutHandler(testOutput); }); - const handler = new SshHandler.default(); + const handler = new SshHandler(); const params = Object.assign({}, ...[DEFAULT_PARAMETERS]); params.arguments.command = "pwd"; params.arguments.cwd = "/user/home"; @@ -114,5 +231,4 @@ describe("issue ssh handler tests", () => { expect(Shell.executeSshCwd).toHaveBeenCalledTimes(1); expect(testOutput).toMatchSnapshot(); }); - }); diff --git a/packages/cli/__tests__/zosuss/__unit__/issue/ssh/__snapshots__/Ssh.handler.unit.test.ts.snap b/packages/cli/__tests__/zosuss/__unit__/issue/ssh/__snapshots__/Ssh.handler.unit.test.ts.snap index 802e164178..e9f13da78c 100644 --- a/packages/cli/__tests__/zosuss/__unit__/issue/ssh/__snapshots__/Ssh.handler.unit.test.ts.snap +++ b/packages/cli/__tests__/zosuss/__unit__/issue/ssh/__snapshots__/Ssh.handler.unit.test.ts.snap @@ -6,4 +6,14 @@ exports[`issue ssh handler tests should be able to get stdout 2`] = `"TEST OUTPU exports[`issue ssh handler tests should be able to get stdout with cwd option 1`] = `"TEST OUTPUT"`; +exports[`issue ssh handler tests should be able to get stdout with private key and key passphrase 1`] = `"TEST OUTPUT"`; + exports[`issue ssh handler tests should be able to get stdout with privateKey 1`] = `"TEST OUTPUT"`; + +exports[`issue ssh handler tests should fail if user fails to enter incorrect key passphrase in 3 attempts 1`] = `"Maximum retry attempts reached. Authentication failed."`; + +exports[`issue ssh handler tests should prompt for user and keyPassphrase if neither is stored 1`] = `"test"`; + +exports[`issue ssh handler tests should prompt user for keyPassphrase if none is stored and privateKey requires one 1`] = `"TEST OUTPUT"`; + +exports[`issue ssh handler tests should reprompt user for keyPassphrase up to 3 times if stored passphrase failed 1`] = `"TEST OUTPUT"`; diff --git a/packages/imperative/CHANGELOG.md b/packages/imperative/CHANGELOG.md index cf776b3df4..71ea5de7f6 100644 --- a/packages/imperative/CHANGELOG.md +++ b/packages/imperative/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the Imperative package will be documented in this file. +## Recent Changes + +- BugFix: Resolved bug that resulted in user not being prompted for a key passphrase if it is located in the secure credential array of the ssh profile. [#1770](https://github.com/zowe/zowe-cli/issues/1770) + ## `5.26.3` - BugFix: Fixed issue in local web help with highlighted sidebar item getting out of sync. [#2215](https://github.com/zowe/zowe-cli/pull/2215) diff --git a/packages/imperative/src/rest/__tests__/session/ConnectionPropsForSessCfg.unit.test.ts b/packages/imperative/src/rest/__tests__/session/ConnectionPropsForSessCfg.unit.test.ts index 36ac3258b4..d84e3f495d 100644 --- a/packages/imperative/src/rest/__tests__/session/ConnectionPropsForSessCfg.unit.test.ts +++ b/packages/imperative/src/rest/__tests__/session/ConnectionPropsForSessCfg.unit.test.ts @@ -20,14 +20,41 @@ import { join } from "path"; import { ConfigAutoStore } from "../../../config/src/ConfigAutoStore"; import { setupConfigToLoad } from "../../../../__tests__/src/TestUtil"; import { IOverridePromptConnProps } from "../../src/session/doc/IOverridePromptConnProps"; - -const certFilePath = join(__dirname, "..", "..", "..", "..", "__tests__", "__integration__", "cmd", - "__tests__", "integration", "cli", "auth", "__resources__", "fakeCert.cert"); -const certKeyFilePath = join(__dirname, "..", "..", "..", "..", "__tests__", "__integration__", "cmd", - "__tests__", "integration", "cli", "auth", "__resources__", "fakeKey.key"); - +import { ISshSession } from "../../../../../zosuss/lib/doc/ISshSession"; +const certFilePath = join( + __dirname, + "..", + "..", + "..", + "..", + "__tests__", + "__integration__", + "cmd", + "__tests__", + "integration", + "cli", + "auth", + "__resources__", + "fakeCert.cert" +); +const certKeyFilePath = join( + __dirname, + "..", + "..", + "..", + "..", + "__tests__", + "__integration__", + "cmd", + "__tests__", + "integration", + "cli", + "auth", + "__resources__", + "fakeKey.key" +); interface extendedSession extends ISession { - someKey?: string + someKey?: string; } describe("ConnectionPropsForSessCfg tests", () => { @@ -1373,4 +1400,68 @@ describe("ConnectionPropsForSessCfg tests", () => { expect(sessCfgWithConnProps.cert).toBeUndefined(); expect(sessCfgWithConnProps.certKey).toBeUndefined(); }); + it("should set default values for elements of propsToPromptFor()", async () => { + jest.spyOn(ConfigAutoStore, "findActiveProfile").mockReturnValueOnce([ + "fruit", + "mango", + ]); + await setupConfigToLoad({ + profiles: { + mango: { + type: "fruit", + properties: {}, + secure: ["host"], + }, + }, + defaults: { fruit: "mango" }, + }); + const overrides: IOverridePromptConnProps[] = [ + { + propertyName: "someKey", + argumentName: "someKeyOther", + propertiesOverridden: [ + "password", + "tokenType", + "tokenValue", + "cert", + "certKey", + ], + }, + ]; + const passFromPrompt = "somePass"; + const initialSessCfg: extendedSession = { + hostname: "SomeHost", + port: 20, + user: "FakeUser", + rejectUnauthorized: true, + }; + const args = { + $0: "zowe", + _: [""], + someKey: "somekeyvalue", + }; + + const commandHandlerPrompt = jest.fn(() => { + return Promise.resolve(passFromPrompt); + }); + const parms = { + response: { + console: { + prompt: commandHandlerPrompt, + }, + }, + }; + const sessCfgWithConnProps: ISshSession = + await ConnectionPropsForSessCfg.addPropsOrPrompt( + initialSessCfg, + args, + { + doPrompting: true, + propertyOverrides: overrides, + propsToPromptFor: [{name: "keyPassphrase",isGivenValueValid: string => true}], + parms: parms as any, + } + ); + expect((ConnectionPropsForSessCfg as any).secureSessCfgProps).toContain("keyPassphrase"); + }); }); diff --git a/packages/imperative/src/rest/src/session/ConnectionPropsForSessCfg.ts b/packages/imperative/src/rest/src/session/ConnectionPropsForSessCfg.ts index 73491702a7..492a7c8686 100644 --- a/packages/imperative/src/rest/src/session/ConnectionPropsForSessCfg.ts +++ b/packages/imperative/src/rest/src/session/ConnectionPropsForSessCfg.ts @@ -97,7 +97,7 @@ export class ConnectionPropsForSessCfg { public static async addPropsOrPrompt( initialSessCfg: SessCfgType, cmdArgs: ICommandArguments, - connOpts: IOptionsForAddConnProps = {} + connOpts: IOptionsForAddConnProps = {} ): Promise { const impLogger = Logger.getImperativeLogger(); @@ -113,8 +113,8 @@ export class ConnectionPropsForSessCfg { ); // This function will provide all the needed properties in one array - const promptForValues: (keyof ISession)[] = []; - const doNotPromptForValues: (keyof ISession)[] = []; + let promptForValues: (keyof SessCfgType & string)[] = []; + const doNotPromptForValues: (keyof SessCfgType & string)[] = []; /* Add the override properties to the session object. */ @@ -134,6 +134,16 @@ export class ConnectionPropsForSessCfg { } } + // Set default values on propsToPromptFor + if(connOpts.propsToPromptFor?.length > 0) + { + connOpts.propsToPromptFor.forEach(obj => { + if(obj.secure == null) obj.secure = true; + if(obj.secure) this.secureSessCfgProps.add(obj.name.toString()); + promptForValues.push(obj.name as keyof ISession); + this.promptTextForValues[obj.name.toString()] = obj.description; + }); + } // check what properties are needed to be prompted if (ConnectionPropsForSessCfg.propHasValue(sessCfgToUse.hostname) === false && !doNotPromptForValues.includes("hostname")) { promptForValues.push("hostname"); @@ -166,6 +176,15 @@ export class ConnectionPropsForSessCfg { // put all the needed properties in an array and call the external function const answers = await connOptsToUse.getValuesBack(promptForValues); + if(connOpts.propsToPromptFor?.length > 0) + { + connOpts.propsToPromptFor.forEach(obj => { + if(obj.isGivenValueValid != null) + { + if(!obj.isGivenValueValid(answers[obj.name])) promptForValues = promptForValues.filter(item => obj.name !== item); + } + }); + } // validate what values are given back and move it to sessCfgToUse for (const value of promptForValues) { if (ConnectionPropsForSessCfg.propHasValue(answers[value])) { @@ -214,7 +233,7 @@ export class ConnectionPropsForSessCfg { public static resolveSessCfgProps( sessCfg: SessCfgType, cmdArgs: ICommandArguments = { $0: "", _: [] }, - connOpts: IOptionsForAddConnProps = {} + connOpts: IOptionsForAddConnProps = {} ) { const impLogger = Logger.getImperativeLogger(); @@ -305,7 +324,7 @@ export class ConnectionPropsForSessCfg { impLogger.debug("Using basic authentication"); sessCfg.type = SessConstants.AUTH_TYPE_BASIC; } - ConnectionPropsForSessCfg.setTypeForTokenRequest(sessCfg, connOpts, cmdArgs.tokenType); + ConnectionPropsForSessCfg.setTypeForTokenRequest(sessCfg, connOpts, cmdArgs.tokenType); ConnectionPropsForSessCfg.logSessCfg(sessCfg); } @@ -356,7 +375,8 @@ export class ConnectionPropsForSessCfg { * @param connOpts Options for adding connection properties * @returns Name-value pairs of connection properties */ - private static getValuesBack(connOpts: IOptionsForAddConnProps): (properties: string[]) => Promise<{ [key: string]: any }> { + private static getValuesBack(connOpts: IOptionsForAddConnProps): + (properties: string[]) => Promise<{ [key: string]: any }> { return async (promptForValues: string[]) => { const answers: { [key: string]: any } = {}; const profileSchema = this.loadSchemaForSessCfgProps(connOpts.parms, promptForValues); @@ -366,7 +386,7 @@ export class ConnectionPropsForSessCfg { let answer; while (answer === undefined) { const hideText = profileSchema[value]?.secure || this.secureSessCfgProps.has(value); - let promptText = `${this.promptTextForValues[value]} ${serviceDescription}`; + let promptText = `${this.promptTextForValues[value] ?? `Enter your ${value} for`} ${serviceDescription}`; if (hideText) { promptText += " (will be hidden)"; } @@ -420,11 +440,11 @@ export class ConnectionPropsForSessCfg { * @param tokenType * The type of token that we expect to receive. */ - private static setTypeForTokenRequest( - sessCfg: any, - options: IOptionsForAddConnProps, + private static setTypeForTokenRequest( + sessCfg: SessCfgType, + options: IOptionsForAddConnProps, tokenType: SessConstants.TOKEN_TYPE_CHOICES - ) { + ) { const impLogger = Logger.getImperativeLogger(); if (options.requestToken) { impLogger.debug("Requesting a token"); diff --git a/packages/imperative/src/rest/src/session/doc/IOptionsForAddConnProps.ts b/packages/imperative/src/rest/src/session/doc/IOptionsForAddConnProps.ts index 706e40a11a..5cece9c5d3 100644 --- a/packages/imperative/src/rest/src/session/doc/IOptionsForAddConnProps.ts +++ b/packages/imperative/src/rest/src/session/doc/IOptionsForAddConnProps.ts @@ -9,18 +9,17 @@ * */ -import { SessConstants } from "../../.."; +import { ISession, SessConstants } from "../../.."; import { IHandlerParameters } from "../../../../cmd"; import { AUTH_TYPE_CHOICES } from "../SessConstants"; import { IOverridePromptConnProps } from "./IOverridePromptConnProps"; - +import { IPropsToPromptFor } from "../doc/IPropsToPromptFor"; /** * Interface for options supplied to ConnectionPropsForSessCfg.addPropsOrPrompt() * @export * @interface ISession */ -export interface IOptionsForAddConnProps { - +export interface IOptionsForAddConnProps { /** * Indicates that we want to generate a token. * When true, we use the user and password for the operation @@ -51,7 +50,13 @@ export interface IOptionsForAddConnProps { * Specifies a list of authentication properties, and what they should override. * If one of these properties is available on the session, do not prompt for the other property. */ - propertyOverrides?: IOverridePromptConnProps[]; + propertyOverrides?: IOverridePromptConnProps[]; + + /** + * Allows passing additional properties for which to prompt. + * Used in cases of an incorrect or missing key passphrase. + */ + propsToPromptFor?: IPropsToPromptFor[]; /** * Specifies the functionality that external applications will use for prompting. diff --git a/packages/imperative/src/rest/src/session/doc/IOverridePromptConnProps.ts b/packages/imperative/src/rest/src/session/doc/IOverridePromptConnProps.ts index db0a928409..868ba5e32b 100644 --- a/packages/imperative/src/rest/src/session/doc/IOverridePromptConnProps.ts +++ b/packages/imperative/src/rest/src/session/doc/IOverridePromptConnProps.ts @@ -16,7 +16,7 @@ import { ISession } from "./ISession"; * @export * @interface IOverridePromptConnProps */ -export interface IOverridePromptConnProps { +export interface IOverridePromptConnProps { /** * Indicates the session property that should be considered in the prompting logic. */ @@ -34,5 +34,11 @@ export interface IOverridePromptConnProps { * Prompting logic is only in place for host, port, user, and password, but cert, certKey, tokenType, and tokenValue may also need * to be overridden. */ - propertiesOverridden: (keyof ISession)[]; + propertiesOverridden: (keyof SessCfgType & string)[]; + + /** + * Allows passing additional properties for which to prompt. + * Used in cases of an incorrect or missing key passphrase. + */ + propsToPromptFor?: (keyof SessCfgType & string)[]; } \ No newline at end of file diff --git a/packages/imperative/src/rest/src/session/doc/IPropsToPromptFor.ts b/packages/imperative/src/rest/src/session/doc/IPropsToPromptFor.ts new file mode 100644 index 0000000000..5cc72b86b3 --- /dev/null +++ b/packages/imperative/src/rest/src/session/doc/IPropsToPromptFor.ts @@ -0,0 +1,19 @@ +/* +* This program and the accompanying materials are made available under the terms of the +* Eclipse Public License v2.0 which accompanies this distribution, and is available at +* https://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Copyright Contributors to the Zowe Project. +* +*/ + +import { ISession } from './ISession'; + +export interface IPropsToPromptFor { + name: keyof SessCfgType & string, + secure?: boolean, + description?: string, + isGivenValueValid?: (givenValue: string) => boolean +} \ No newline at end of file diff --git a/packages/zosuss/CHANGELOG.md b/packages/zosuss/CHANGELOG.md index f302fecf37..7088ff50e5 100644 --- a/packages/zosuss/CHANGELOG.md +++ b/packages/zosuss/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to the Zowe z/OS USS SDK package will be documented in this file. +## Recent Changes + +- BugFix: Resolved bug that resulted in user not being prompted for a key passphrase if it is located in the secure credential array of the ssh profile. [#1770](https://github.com/zowe/zowe-cli/issues/1770) +- Enhancement: `SshBaseHandler` command processor will now prompt user up to 3 times to enter the correct keyPassphrase in the case that the stored value is incorrect or no value is stored. [#1770](https://github.com/zowe/zowe-cli/issues/1770) + ## `7.28.3` - BugFix: Refactored code to reduce the use of deprecated functions to prepare for upcoming Node.js 22 support. [#2191](https://github.com/zowe/zowe-cli/issues/2191) diff --git a/packages/zosuss/__tests__/__unit__/SshBaseHandler.unit.test.ts b/packages/zosuss/__tests__/__unit__/SshBaseHandler.unit.test.ts new file mode 100644 index 0000000000..5e9365250a --- /dev/null +++ b/packages/zosuss/__tests__/__unit__/SshBaseHandler.unit.test.ts @@ -0,0 +1,231 @@ +/* +* This program and the accompanying materials are made available under the terms of the +* Eclipse Public License v2.0 which accompanies this distribution, and is available at +* https://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Copyright Contributors to the Zowe Project. +* +*/ + +import { IHandlerParameters, IProfile, CommandProfiles, ConnectionPropsForSessCfg } from "@zowe/imperative"; +import { mockHandlerParameters } from "@zowe/cli-test-utils"; +import { join, normalize } from "path"; +import { Shell } from "../../src/Shell"; +import { SshBaseHandler } from "../../src/SshBaseHandler"; +import * as fs from "fs"; + +process.env.FORCE_COLOR = "0"; + +const UNIT_TEST_SSH_PROF_OPTS = { + host: "somewhere.com", + port: "22", + user: "someone", + password: "somesecret" +}; + +const UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY = { + host: "somewhere.com", + port: "22", + user: "someone", + privateKey: normalize(join(__dirname, "..", "..", "..", "..", "..", "..", "zosuss", "__tests__", "__unit__", "__resources__", "fake_id_rsa")) +}; +const UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE = { + host: "somewhere.com", + port: "22", + user: "someone", + privateKey: normalize(join(__dirname, "..", "..", "..", "..", "..", "..", "zosuss", "__tests__", "__unit__", "__resources__", "fake_id_rsa")), + keyPassPhrase: "dummyPassPhrase123" +}; +const UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER = { + host: "somewhere.com", + port: "22", + privateKey: normalize(join(__dirname, "..", "..", "..", "..", "..", "..", "zosuss", "__tests__", "__unit__", "__resources__", "fake_id_rsa")), + keyPassPhrase: "dummyPassPhrase123" +}; + + +// A mocked profile map with ssh profile +const UNIT_TEST_PROFILE_MAP = new Map(); +UNIT_TEST_PROFILE_MAP.set( + "ssh", [{ + name: "ssh", + type: "ssh", + ...UNIT_TEST_SSH_PROF_OPTS + }] +); +const UNIT_TEST_PROFILES_SSH = new CommandProfiles(UNIT_TEST_PROFILE_MAP); + +const UNIT_TEST_PROFILE_MAP_PRIVATE_KEY = new Map(); +UNIT_TEST_PROFILE_MAP_PRIVATE_KEY.set( + "ssh", [{ + name: "ssh", + type: "ssh", + ...UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY + }] +); +const UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE = new Map(); +UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE.set( + "ssh", [{ + name: "ssh", + type: "ssh", + ...UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE + }] +); +const UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER = new Map(); +UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE.set( + "ssh", [{ + name: "ssh", + type: "ssh", + ...UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER + }] +); + +const UNIT_TEST_PROFILES_SSH_PRIVATE_KEY = new CommandProfiles(UNIT_TEST_PROFILE_MAP_PRIVATE_KEY); +const UNIT_TEST_PROFILES_SSH_PRIVATE_KEY_WITH_PASSPHRASE = new CommandProfiles(UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE); +const UNIT_TEST_PROFILES_SSH_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER = new CommandProfiles(UNIT_TEST_PROFILE_MAP_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER); + +// Mocked parameters for the unit tests +const DEFAULT_PARAMETERS: IHandlerParameters = mockHandlerParameters({ + arguments: UNIT_TEST_SSH_PROF_OPTS, + positionals: ["zos-uss", "issue", "ssh"], + definition: {} as any, + profiles: UNIT_TEST_PROFILES_SSH +}); + +const DEFAULT_PARAMETERS_PRIVATE_KEY: IHandlerParameters = mockHandlerParameters({ + arguments: UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY, + positionals: ["zos-uss", "issue", "ssh"], + definition: {} as any, + profiles: UNIT_TEST_PROFILES_SSH_PRIVATE_KEY +}); + +const DEFAULT_PARAMETERS_KEY_PASSPHRASE: IHandlerParameters = mockHandlerParameters({ + arguments: UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE, + positionals: ["zos-uss", "issue", "ssh"], + definition: {} as any, + profiles: UNIT_TEST_PROFILES_SSH_PRIVATE_KEY_WITH_PASSPHRASE, +}); +const DEFAULT_PARAMETERS_KEY_PASSPHRASE_NO_USER: IHandlerParameters = mockHandlerParameters({ + arguments: UNIT_TEST_SSH_PROF_OPTS_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER, + positionals: ["zos-uss", "issue", "ssh"], + definition: {} as any, + profiles: UNIT_TEST_PROFILES_SSH_PRIVATE_KEY_WITH_PASSPHRASE_NO_USER, +}); + +class myHandler extends SshBaseHandler { + public async processCmd(commandParameters: IHandlerParameters): Promise { + return await Shell.executeSsh( + this.mSession, + commandParameters.arguments.command, + (data: any) => commandParameters.response.console.log(Buffer.from(data)) + ); + } +} +const testOutput = "TEST OUTPUT"; + +describe("issue ssh handler tests", () => { + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should be able to get stdout", async () => { + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new myHandler(); + const params = Object.assign({}, ...[DEFAULT_PARAMETERS]); + params.arguments.command = "pwd"; + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); + + it("should be able to get stdout with private key and key passphrase", async () => { + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new myHandler(); + const params = Object.assign({}, ...[DEFAULT_PARAMETERS_KEY_PASSPHRASE]); + params.arguments.command = "echo test"; + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); + it("should prompt user for keyPassphrase if none is stored and privateKey requires one", async () => { + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + jest.spyOn(fs,"readFileSync").mockReturnValue("dummyPrivateKey"); + const handler = new myHandler(); + jest.spyOn(handler,"processCmd").mockImplementationOnce(() => {throw new Error("but no passphrase given");}); + const params = Object.assign({}, ...[DEFAULT_PARAMETERS_KEY_PASSPHRASE]); + params.arguments.command = "echo test"; + jest.spyOn(ConnectionPropsForSessCfg as any,"getValuesBack").mockReturnValue(() => ({ + keyPassphrase: "validPassword" + })); + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); + it("should reprompt user for keyPassphrase up to 3 times if stored passphrase failed", async () => { + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + jest.spyOn(fs,"readFileSync").mockReturnValue("dummyPrivateKey"); + const handler = new myHandler(); + const params = Object.assign({}, ...[DEFAULT_PARAMETERS_KEY_PASSPHRASE]); + params.arguments.command = "echo test"; + jest.spyOn(handler,"processCmd").mockImplementationOnce(() => {throw new Error("bad passphrase?");}); + jest.spyOn(ConnectionPropsForSessCfg as any,"getValuesBack").mockReturnValue(() => ({ + keyPassphrase: "validPassword" + })); + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); + it("should fail if user fails to enter incorrect key passphrase in 3 attempts", async () => { + const testOutput = "Maximum retry attempts reached. Authentication failed."; + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new myHandler(); + const params = { ...DEFAULT_PARAMETERS_KEY_PASSPHRASE }; + params.arguments.command = "echo test"; + jest.spyOn(handler, "processCmd").mockImplementation(() => { + throw new Error("bad passphrase?"); + }); + await expect(handler.process(params)).rejects.toThrow("Maximum retry attempts reached. Authentication failed."); + expect(handler.processCmd).toHaveBeenCalledTimes(4); + expect(testOutput).toMatchSnapshot(); + }); + it("should prompt for user and keyPassphrase if neither is stored", async () => { + const testOutput = "test"; + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new myHandler(); + const params = { ...DEFAULT_PARAMETERS_KEY_PASSPHRASE_NO_USER }; + params.arguments.command = "echo test"; + jest.spyOn(ConnectionPropsForSessCfg as any,"getValuesBack").mockReturnValue(() => ({ + user: "someone", + keyPassphrase: "validPassword" + })); + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); + it("should be able to get stdout with privateKey", async () => { + Shell.executeSsh = jest.fn(async (session, command, stdoutHandler) => { + stdoutHandler(testOutput); + }); + const handler = new myHandler(); + const params = Object.assign({}, ...[DEFAULT_PARAMETERS_PRIVATE_KEY]); + params.arguments.command = "pwd"; + await handler.process(params); + expect(Shell.executeSsh).toHaveBeenCalledTimes(1); + expect(testOutput).toMatchSnapshot(); + }); +}); diff --git a/packages/zosuss/__tests__/__unit__/__snapshots__/SshBaseHandler.unit.test.ts.snap b/packages/zosuss/__tests__/__unit__/__snapshots__/SshBaseHandler.unit.test.ts.snap new file mode 100644 index 0000000000..45544acebb --- /dev/null +++ b/packages/zosuss/__tests__/__unit__/__snapshots__/SshBaseHandler.unit.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`issue ssh handler tests should be able to get stdout 1`] = `"TEST OUTPUT"`; + +exports[`issue ssh handler tests should be able to get stdout 2`] = `"TEST OUTPUT"`; + +exports[`issue ssh handler tests should be able to get stdout with private key and key passphrase 1`] = `"TEST OUTPUT"`; + +exports[`issue ssh handler tests should be able to get stdout with privateKey 1`] = `"TEST OUTPUT"`; + +exports[`issue ssh handler tests should fail if user fails to enter incorrect key passphrase in 3 attempts 1`] = `"Maximum retry attempts reached. Authentication failed."`; + +exports[`issue ssh handler tests should prompt for user and keyPassphrase if neither is stored 1`] = `"test"`; + +exports[`issue ssh handler tests should prompt user for keyPassphrase if none is stored and privateKey requires one 1`] = `"TEST OUTPUT"`; + +exports[`issue ssh handler tests should reprompt user for keyPassphrase up to 3 times if stored passphrase failed 1`] = `"TEST OUTPUT"`; diff --git a/packages/zosuss/src/SshBaseHandler.ts b/packages/zosuss/src/SshBaseHandler.ts index b824d2a13f..d3927573e4 100644 --- a/packages/zosuss/src/SshBaseHandler.ts +++ b/packages/zosuss/src/SshBaseHandler.ts @@ -15,7 +15,6 @@ import { ICommandHandler, IOverridePromptConnProps, IHandlerParameters, - IProfile, IHandlerResponseConsoleApi, IHandlerFormatOutputApi, IHandlerResponseDataApi, @@ -23,28 +22,22 @@ import { IImperativeError, ImperativeError, ConnectionPropsForSessCfg, - SessConstants + SessConstants, } from "@zowe/imperative"; import { SshSession } from "./SshSession"; import { ISshSession } from "./doc/ISshSession"; - +import { utils } from "ssh2"; +import * as fs from "fs"; /** * This class is used by the various handlers in the project as the base class for their implementation. */ export abstract class SshBaseHandler implements ICommandHandler { - /** * The session creating from the command line arguments / profile */ protected mSession: SshSession; - /** - * Loaded z/OS SSH profile if needed - * @deprecated - */ - protected mSshProfile: IProfile; - /** * Command line arguments passed */ @@ -65,26 +58,108 @@ export abstract class SshBaseHandler implements ICommandHandler { */ public async process(commandParameters: IHandlerParameters) { this.mHandlerParams = commandParameters; - // Why is this here? NOTHING uses it, but I suppose an extender MIGHT be... -awharn - // eslint-disable-next-line deprecation/deprecation - this.mSshProfile = commandParameters.profiles.get("ssh", false); - const sshSessCfgOverride: IOverridePromptConnProps[] = [{ - propertyName: "privateKey", - propertiesOverridden: ["password", "tokenType", "tokenValue", "cert", "certKey", "passphrase"] - }]; - const sshSessCfg: ISshSession = SshSession.createSshSessCfgFromArgs(commandParameters.arguments); - const sshSessCfgWithCreds = await ConnectionPropsForSessCfg.addPropsOrPrompt( - sshSessCfg, commandParameters.arguments, { - parms: commandParameters, - propertyOverrides: sshSessCfgOverride, - supportedAuthTypes: [SessConstants.AUTH_TYPE_BASIC] - } + const sshSessCfgOverride: IOverridePromptConnProps[] = [ + { + propertyName: "privateKey", + propertiesOverridden: [ + "password", + "tokenType", + "tokenValue", + "cert", + "certKey", + ], + }, + ]; + const sshSessCfg: ISshSession = SshSession.createSshSessCfgFromArgs( + commandParameters.arguments ); + let sshSessCfgWithCreds = + await ConnectionPropsForSessCfg.addPropsOrPrompt( + sshSessCfg, + commandParameters.arguments, + { + parms: commandParameters, + propertyOverrides: sshSessCfgOverride, + supportedAuthTypes: [SessConstants.AUTH_TYPE_BASIC], + } + ); this.mSession = new SshSession(sshSessCfgWithCreds); this.mArguments = commandParameters.arguments; - await this.processCmd(commandParameters); + + try { + await this.processCmd(commandParameters); + } catch (e) { + if ( + e.message.includes("but no passphrase given") || + e.message.includes("bad passphrase?") + ) { + this.console.log("Initial key passphrase authentication failed!" + "\n"); + const maxAttempts = 3; + let attempt = 0; + let success = false; + while (attempt < maxAttempts && !success) { + try { + sshSessCfgWithCreds = + await ConnectionPropsForSessCfg.addPropsOrPrompt( + sshSessCfgWithCreds, + commandParameters.arguments, + { + parms: commandParameters, + propertyOverrides: sshSessCfgOverride, + supportedAuthTypes: [ + SessConstants.AUTH_TYPE_BASIC, + ], + propsToPromptFor: [ + { + name: "keyPassphrase", + isGivenValueValid: (givenValue: string) => { + let saveKP: boolean = true; + const result = utils.parseKey( + fs.readFileSync( + sshSessCfgWithCreds.privateKey + ), + givenValue + ); + if (result instanceof Error) + saveKP = + !result.message.includes( + "no passphrase given" + ) && + !result.message.includes( + "bad passphrase" + ); + return saveKP; + }, + }, + ], + } + ); + this.mSession = new SshSession(sshSessCfgWithCreds); + await this.processCmd(commandParameters); + success = true; + } catch (retryError) { + this.console.log( + "\n" + + `Key passphrase authentication failed! (${ + ++attempt + }/${maxAttempts})` + + "\n" + ); + if (attempt >= maxAttempts) { + throw new Error( + "Maximum retry attempts reached. Authentication failed." + ); + } + } + } + } + else + { + throw e; + } + } } /** @@ -133,5 +208,7 @@ export abstract class SshBaseHandler implements ICommandHandler { * @param {IHandlerParameters} commandParameters Command parameters sent to the handler. * */ - public abstract processCmd(commandParameters: IHandlerParameters): Promise; + public abstract processCmd( + commandParameters: IHandlerParameters + ): Promise; } From c493f2750b993b183b58d8c8b7e89b70772e0a8e Mon Sep 17 00:00:00 2001 From: jace-roell Date: Mon, 12 Aug 2024 15:18:08 -0400 Subject: [PATCH 2/4] readded deprecated method to avoid a breaking change Signed-off-by: jace-roell --- packages/zosuss/src/SshBaseHandler.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/zosuss/src/SshBaseHandler.ts b/packages/zosuss/src/SshBaseHandler.ts index d3927573e4..f596c8d8d5 100644 --- a/packages/zosuss/src/SshBaseHandler.ts +++ b/packages/zosuss/src/SshBaseHandler.ts @@ -15,6 +15,7 @@ import { ICommandHandler, IOverridePromptConnProps, IHandlerParameters, + IProfile, IHandlerResponseConsoleApi, IHandlerFormatOutputApi, IHandlerResponseDataApi, @@ -22,12 +23,10 @@ import { IImperativeError, ImperativeError, ConnectionPropsForSessCfg, - SessConstants, + SessConstants } from "@zowe/imperative"; import { SshSession } from "./SshSession"; import { ISshSession } from "./doc/ISshSession"; -import { utils } from "ssh2"; -import * as fs from "fs"; /** * This class is used by the various handlers in the project as the base class for their implementation. @@ -38,6 +37,12 @@ export abstract class SshBaseHandler implements ICommandHandler { */ protected mSession: SshSession; + /** + * Loaded z/OS SSH profile if needed + * @deprecated + */ + protected mSshProfile: IProfile; + /** * Command line arguments passed */ @@ -58,6 +63,9 @@ export abstract class SshBaseHandler implements ICommandHandler { */ public async process(commandParameters: IHandlerParameters) { this.mHandlerParams = commandParameters; + // Why is this here? NOTHING uses it, but I suppose an extender MIGHT be... -awharn + // eslint-disable-next-line deprecation/deprecation + this.mSshProfile = commandParameters.profiles.get("ssh", false); const sshSessCfgOverride: IOverridePromptConnProps[] = [ { From 0ef43dc9fe52aa0df0b8b2545776ee2bc174cda6 Mon Sep 17 00:00:00 2001 From: jace-roell Date: Mon, 12 Aug 2024 15:27:00 -0400 Subject: [PATCH 3/4] dependency Signed-off-by: jace-roell --- packages/zosuss/src/SshBaseHandler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/zosuss/src/SshBaseHandler.ts b/packages/zosuss/src/SshBaseHandler.ts index f596c8d8d5..81e4f388f1 100644 --- a/packages/zosuss/src/SshBaseHandler.ts +++ b/packages/zosuss/src/SshBaseHandler.ts @@ -15,7 +15,6 @@ import { ICommandHandler, IOverridePromptConnProps, IHandlerParameters, - IProfile, IHandlerResponseConsoleApi, IHandlerFormatOutputApi, IHandlerResponseDataApi, @@ -23,10 +22,13 @@ import { IImperativeError, ImperativeError, ConnectionPropsForSessCfg, - SessConstants + SessConstants, + IProfile } from "@zowe/imperative"; import { SshSession } from "./SshSession"; import { ISshSession } from "./doc/ISshSession"; +import { utils } from "ssh2"; +import * as fs from "fs"; /** * This class is used by the various handlers in the project as the base class for their implementation. From 6533e99be57a11d53fc02a2502e41c212a1a298f Mon Sep 17 00:00:00 2001 From: jace-roell Date: Mon, 12 Aug 2024 16:17:24 -0400 Subject: [PATCH 4/4] fixed nested literals warning from sonarcloud Signed-off-by: jace-roell --- .../src/rest/src/session/ConnectionPropsForSessCfg.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/imperative/src/rest/src/session/ConnectionPropsForSessCfg.ts b/packages/imperative/src/rest/src/session/ConnectionPropsForSessCfg.ts index 492a7c8686..2396bc8230 100644 --- a/packages/imperative/src/rest/src/session/ConnectionPropsForSessCfg.ts +++ b/packages/imperative/src/rest/src/session/ConnectionPropsForSessCfg.ts @@ -386,7 +386,8 @@ export class ConnectionPropsForSessCfg { let answer; while (answer === undefined) { const hideText = profileSchema[value]?.secure || this.secureSessCfgProps.has(value); - let promptText = `${this.promptTextForValues[value] ?? `Enter your ${value} for`} ${serviceDescription}`; + const valuePrompt = this.promptTextForValues[value] ?? `Enter your ${value} for`; + let promptText = `${valuePrompt} ${serviceDescription}`; if (hideText) { promptText += " (will be hidden)"; }