This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 7b6dea7b1f Replace sshlib with JSch in the SSH transform plugin (#7203)
7b6dea7b1f is described below

commit 7b6dea7b1fd62bea82fc2d903ccd70e6f9413009
Author: Lance <[email protected]>
AuthorDate: Tue Jun 2 21:53:58 2026 +0800

    Replace sshlib with JSch in the SSH transform plugin (#7203)
    
    * Replace sshlib with JSch in the SSH transform plugin
    
    Signed-off-by: lance <[email protected]>
    
    * add integration tests
    
    ---------
    
    Signed-off-by: lance <[email protected]>
    Co-authored-by: Hans Van Akelyen <[email protected]>
---
 .dockerignore                                      |   1 +
 .../{ssh.Dockerfile => integration-tests-ssh.yaml} |  31 +--
 docker/integration-tests/ssh.Dockerfile            |  15 +-
 integration-tests/ssh/dev-env-config.json          |  24 ++
 integration-tests/ssh/hop-config.json              | 290 +++++++++++++++++++++
 integration-tests/ssh/keys/it_rsa                  |  27 ++
 integration-tests/ssh/keys/it_rsa.pub              |   1 +
 .../ssh/main-0001-ssh-command-password.hwf         |  94 +++++++
 .../ssh/main-0002-ssh-command-private-key.hwf      |  94 +++++++
 .../metadata/pipeline-run-configuration/local.json |  22 ++
 .../metadata/workflow-run-configuration/local.json |  11 +
 integration-tests/ssh/project-config.json          |  13 +
 integration-tests/ssh/ssh-command-password.hpl     | 164 ++++++++++++
 integration-tests/ssh/ssh-command-private-key.hpl  | 164 ++++++++++++
 plugins/transforms/ssh/pom.xml                     |  25 +-
 plugins/transforms/ssh/src/assembly/assembly.xml   |   5 +-
 .../hop/pipeline/transforms/ssh/SessionResult.java | 152 ++++++-----
 .../apache/hop/pipeline/transforms/ssh/Ssh.java    |  50 ++--
 .../hop/pipeline/transforms/ssh/SshData.java       | 107 ++++----
 .../hop/pipeline/transforms/ssh/SshDialog.java     |  10 +-
 .../hop/pipeline/transforms/ssh/SshMeta.java       | 140 +---------
 .../src/main/samples/transforms/ssh-password.hpl   |  97 +++++++
 .../src/main/samples/transforms/ssh-pri-key.hpl    |  97 +++++++
 .../pipeline/transforms/ssh/SessionResultTest.java |  83 ++++++
 .../hop/pipeline/transforms/ssh/SshDataTest.java   | 145 +++++++++++
 .../hop/pipeline/transforms/ssh/SshMetaTest.java   | 213 ++++++++++++++-
 .../pipeline/transforms/ssh/SshTestSupport.java    | 217 +++++++++++++++
 .../pipeline/transforms/ssh/SshTransformTest.java  | 248 ++++++++++++++++++
 pom.xml                                            |   1 +
 29 files changed, 2219 insertions(+), 322 deletions(-)

diff --git a/.dockerignore b/.dockerignore
index da90c3875b..71f30a9ef7 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,6 +5,7 @@
 !/assemblies/client/target/hop-*
 !/assemblies/client/target/hop
 !/integration-tests/scripts
+!/integration-tests/ssh/keys
 !/assemblies/web/target
 !/assemblies/plugins/target
 !/docker
diff --git a/docker/integration-tests/ssh.Dockerfile 
b/docker/integration-tests/integration-tests-ssh.yaml
similarity index 53%
copy from docker/integration-tests/ssh.Dockerfile
copy to docker/integration-tests/integration-tests-ssh.yaml
index fab96c1a11..e55ffef12d 100644
--- a/docker/integration-tests/ssh.Dockerfile
+++ b/docker/integration-tests/integration-tests-ssh.yaml
@@ -1,4 +1,3 @@
-#
 # Licensed to the Apache Software Foundation (ASF) under one
 # or more contributor license agreements.  See the NOTICE file
 # distributed with this work for additional information
@@ -15,21 +14,19 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-#
-# Minimal SSH server for integration tests: allows password auth and TCP 
forwarding
-# so Hop can tunnel to Postgres through this container.
-#
-FROM alpine:3.19
-
-RUN apk add --no-cache openssh && \
-    ssh-keygen -A && \
-    adduser -D -s /bin/sh hop && \
-    echo "hop:hop_ssh_password" | chpasswd && \
-    sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config 
&& \
-    sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/' 
/etc/ssh/sshd_config && \
-    sed -i 's/^#\?AllowTcpForwarding.*/AllowTcpForwarding yes/' 
/etc/ssh/sshd_config && \
-    sed -i 's/^#\?GatewayPorts.*/GatewayPorts no/' /etc/ssh/sshd_config
 
-EXPOSE 22
+services:
+  integration_test_ssh:
+    extends:
+      file: integration-tests-base.yaml
+      service: integration_test
+    depends_on:
+      ssh:
+        condition: service_started
+    links:
+      - ssh
 
-CMD ["/usr/sbin/sshd", "-D"]
+  ssh:
+    build:
+      context: ../../.
+      dockerfile: docker/integration-tests/ssh.Dockerfile
diff --git a/docker/integration-tests/ssh.Dockerfile 
b/docker/integration-tests/ssh.Dockerfile
index fab96c1a11..5e8ee87af7 100644
--- a/docker/integration-tests/ssh.Dockerfile
+++ b/docker/integration-tests/ssh.Dockerfile
@@ -17,7 +17,9 @@
 # under the License.
 #
 # Minimal SSH server for integration tests: allows password auth and TCP 
forwarding
-# so Hop can tunnel to Postgres through this container.
+# so Hop can tunnel to Postgres through this container. Public-key auth is also
+# enabled for the SSH transform private-key integration test; the matching key
+# pair lives in integration-tests/ssh/keys (test-only, never used outside the 
IT).
 #
 FROM alpine:3.19
 
@@ -27,9 +29,20 @@ RUN apk add --no-cache openssh && \
     echo "hop:hop_ssh_password" | chpasswd && \
     sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config 
&& \
     sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/' 
/etc/ssh/sshd_config && \
+    sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' 
/etc/ssh/sshd_config && \
     sed -i 's/^#\?AllowTcpForwarding.*/AllowTcpForwarding yes/' 
/etc/ssh/sshd_config && \
     sed -i 's/^#\?GatewayPorts.*/GatewayPorts no/' /etc/ssh/sshd_config
 
+# Authorize the integration-test public key for the hop user. sshd enforces
+# StrictModes, so ownership and permissions must be exact or pubkey auth fails.
+COPY integration-tests/ssh/keys/it_rsa.pub /tmp/it_rsa.pub
+RUN mkdir -p /home/hop/.ssh && \
+    cat /tmp/it_rsa.pub > /home/hop/.ssh/authorized_keys && \
+    rm -f /tmp/it_rsa.pub && \
+    chown -R hop:hop /home/hop/.ssh && \
+    chmod 700 /home/hop/.ssh && \
+    chmod 600 /home/hop/.ssh/authorized_keys
+
 EXPOSE 22
 
 CMD ["/usr/sbin/sshd", "-D"]
diff --git a/integration-tests/ssh/dev-env-config.json 
b/integration-tests/ssh/dev-env-config.json
new file mode 100644
index 0000000000..c9af17cfe6
--- /dev/null
+++ b/integration-tests/ssh/dev-env-config.json
@@ -0,0 +1,24 @@
+{
+  "variables" : [
+    {
+      "name" : "SSH_HOSTNAME",
+      "value" : "ssh",
+      "description" : "Hostname of the SSH server container (service name in 
integration-tests-ssh.yaml)"
+    },
+    {
+      "name" : "SSH_USER",
+      "value" : "hop",
+      "description" : "SSH user created in 
docker/integration-tests/ssh.Dockerfile"
+    },
+    {
+      "name" : "SSH_PASSWORD",
+      "value" : "hop_ssh_password",
+      "description" : "Password for the SSH user (see ssh.Dockerfile)"
+    },
+    {
+      "name" : "SSH_PORT",
+      "value" : "22",
+      "description" : ""
+    }
+  ]
+}
diff --git a/integration-tests/ssh/hop-config.json 
b/integration-tests/ssh/hop-config.json
new file mode 100644
index 0000000000..102ac981d5
--- /dev/null
+++ b/integration-tests/ssh/hop-config.json
@@ -0,0 +1,290 @@
+{
+  "variables": [
+    {
+      "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION",
+      "value": "N",
+      "description": "System wide flag to allow lenient string to number 
conversion for backward compatibility. If this setting is set to \"Y\", an 
string starting with digits will be converted successfully into a number. 
(example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending 
on the decimal and grouping symbol). The default (N) will be to throw an error 
if non-numeric symbols are found in the string."
+    },
+    {
+      "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE",
+      "value": "N",
+      "description": "System wide flag to ignore timezone while writing 
date/timestamp value to the database."
+    },
+    {
+      "name": "HOP_LOG_SIZE_LIMIT",
+      "value": "0",
+      "description": "The log size limit for all pipelines and workflows that 
don't have the \"log size limit\" property set in their respective properties."
+    },
+    {
+      "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL",
+      "value": "N",
+      "description": "NULL vs Empty String. If this setting is set to Y, an 
empty string and null are different. Otherwise they are not."
+    },
+    {
+      "name": "HOP_MAX_LOG_SIZE_IN_LINES",
+      "value": "0",
+      "description": "The maximum number of log lines that are kept internally 
by Hop. Set to 0 to keep all rows (default)"
+    },
+    {
+      "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES",
+      "value": "1440",
+      "description": "The maximum age (in minutes) of a log line while being 
kept internally by Hop. Set to 0 to keep all rows indefinitely (default)"
+    },
+    {
+      "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE",
+      "value": "5000",
+      "description": "The maximum number of workflow trackers kept in memory"
+    },
+    {
+      "name": "HOP_MAX_ACTIONS_LOGGED",
+      "value": "5000",
+      "description": "The maximum number of action results kept in memory for 
logging purposes."
+    },
+    {
+      "name": "HOP_MAX_LOGGING_REGISTRY_SIZE",
+      "value": "10000",
+      "description": "The maximum number of logging registry entries kept in 
memory for logging purposes."
+    },
+    {
+      "name": "HOP_LOG_TAB_REFRESH_DELAY",
+      "value": "1000",
+      "description": "The hop log tab refresh delay."
+    },
+    {
+      "name": "HOP_LOG_TAB_REFRESH_PERIOD",
+      "value": "1000",
+      "description": "The hop log tab refresh period."
+    },
+    {
+      "name": "HOP_PLUGIN_CLASSES",
+      "value": null,
+      "description": "A comma delimited list of classes to scan for plugin 
annotations"
+    },
+    {
+      "name": "HOP_PLUGIN_PACKAGES",
+      "value": null,
+      "description": "A comma delimited list of packages to scan for plugin 
annotations (warning: slow!!)"
+    },
+    {
+      "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT",
+      "value": "0",
+      "description": "The maximum number of transform performance snapshots to 
keep in memory. Set to 0 to keep all snapshots indefinitely (default)"
+    },
+    {
+      "name": "HOP_ROWSET_GET_TIMEOUT",
+      "value": "50",
+      "description": "The name of the variable that optionally contains an 
alternative rowset get timeout (in ms). This only makes a difference for 
extremely short lived pipelines."
+    },
+    {
+      "name": "HOP_ROWSET_PUT_TIMEOUT",
+      "value": "50",
+      "description": "The name of the variable that optionally contains an 
alternative rowset put timeout (in ms). This only makes a difference for 
extremely short lived pipelines."
+    },
+    {
+      "name": "HOP_CORE_TRANSFORMS_FILE",
+      "value": null,
+      "description": "The name of the project variable that will contain the 
alternative location of the hop-transforms.xml file. You can use this to 
customize the list of available internal transforms outside of the codebase."
+    },
+    {
+      "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE",
+      "value": null,
+      "description": "The name of the project variable that will contain the 
alternative location of the hop-workflow-actions.xml file."
+    },
+    {
+      "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES",
+      "value": "1440",
+      "description": "This project variable will set a time-out after which 
waiting, completed or stopped pipelines and workflows will be automatically 
cleaned up. The default value is 1440 (one day)."
+    },
+    {
+      "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE",
+      "value": null,
+      "description": "Set this variable to an integer that will be returned as 
the Pan JVM exit code."
+    },
+    {
+      "name": "HOP_DISABLE_CONSOLE_LOGGING",
+      "value": "N",
+      "description": "Set this variable to Y to disable standard Hop logging 
to the console. (stdout)"
+    },
+    {
+      "name": "HOP_REDIRECT_STDERR",
+      "value": "N",
+      "description": "Set this variable to Y to redirect stderr to Hop 
logging."
+    },
+    {
+      "name": "HOP_REDIRECT_STDOUT",
+      "value": "N",
+      "description": "Set this variable to Y to redirect stdout to Hop 
logging."
+    },
+    {
+      "name": "HOP_DEFAULT_NUMBER_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default number format"
+    },
+    {
+      "name": "HOP_DEFAULT_BIGNUMBER_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default bignumber format"
+    },
+    {
+      "name": "HOP_DEFAULT_INTEGER_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default integer format"
+    },
+    {
+      "name": "HOP_DEFAULT_DATE_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default date format"
+    },
+    {
+      "name": "HOP_DEFAULT_TIMESTAMP_FORMAT",
+      "value": null,
+      "description": "The name of the variable containing an alternative 
default timestamp format"
+    },
+    {
+      "name": "HOP_DEFAULT_SERVLET_ENCODING",
+      "value": null,
+      "description": "Defines the default encoding for servlets, leave it 
empty to use Java default encoding"
+    },
+    {
+      "name": "HOP_FAIL_ON_LOGGING_ERROR",
+      "value": "N",
+      "description": "Set this variable to Y when you want the 
workflow/pipeline fail with an error when the related logging process (e.g. to 
a database) fails."
+    },
+    {
+      "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED",
+      "value": "N",
+      "description": "Set this variable to Y to set the minimum to NULL if 
NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN 
aggregate and MIN is set to the minimum value that is not NULL. See also the 
variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO."
+    },
+    {
+      "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO",
+      "value": "N",
+      "description": "Set this variable to Y to return 0 when all values 
within an aggregate are NULL. Otherwise by default a NULL is returned when all 
values are NULL."
+    },
+    {
+      "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER",
+      "value": "N",
+      "description": "Set this variable to Y for backward compatibility for 
the Text File Output transform. Setting this to Ywill add no header row at all 
when the append option is enabled, regardless if the file is existing or not."
+    },
+    {
+      "name": "HOP_PASSWORD_ENCODER_PLUGIN",
+      "value": "Hop",
+      "description": "Specifies the password encoder plugin to use by ID (Hop 
is the default)."
+    },
+    {
+      "name": "HOP_SYSTEM_HOSTNAME",
+      "value": null,
+      "description": "You can use this variable to speed up hostname lookup. 
Hostname lookup is performed by Hop so that it is capable of logging the server 
on which a workflow or pipeline is executed."
+    },
+    {
+      "name": "HOP_SERVER_JETTY_ACCEPTORS",
+      "value": null,
+      "description": "A variable to configure jetty option: acceptors for 
Carte"
+    },
+    {
+      "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE",
+      "value": null,
+      "description": "A variable to configure jetty option: acceptQueueSize 
for Carte"
+    },
+    {
+      "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME",
+      "value": null,
+      "description": "A variable to configure jetty option: 
lowResourcesMaxIdleTime for Carte"
+    },
+    {
+      "name": 
"HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL",
+      "value": "N",
+      "description": "Set this variable to Y for backward compatibility for 
the Merge Rows (diff) transform. Setting this to Y will use the data from the 
reference stream (instead of the comparison stream) in case the compared rows 
are identical."
+    },
+    {
+      "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE",
+      "value": "false",
+      "description": "Set this variable to false to preserve enclosure symbol 
after splitting the string in the Split fields transform. Changing it to true 
will remove first and last enclosure symbol from the resulting string chunks."
+    },
+    {
+      "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES",
+      "value": "false",
+      "description": "Set this variable to TRUE to allow your pipeline to pass 
'null' fields and/or empty types."
+    },
+    {
+      "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT",
+      "value": "false",
+      "description": "Set this variable to false to preserve global log 
variables defined in pipeline / workflow Properties -> Log panel. Changing it 
to true will clear it when export pipeline / workflow."
+    },
+    {
+      "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT",
+      "value": "1024",
+      "description": "This project variable is used by the Text File Output 
transform. It defines the max number of simultaneously open files within the 
transform. The transform will close/reopen files as necessary to insure the max 
is not exceeded"
+    },
+    {
+      "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE",
+      "value": "0",
+      "description": "This project variable is used by the Text File Output 
transform. It defines the max number of milliseconds between flushes of files 
opened by the transform."
+    },
+    {
+      "name": "HOP_USE_NATIVE_FILE_DIALOG",
+      "value": "N",
+      "description": "Set this value to Y if you want to use the system file 
open/save dialog when browsing files"
+    },
+    {
+      "name": "HOP_AUTO_CREATE_CONFIG",
+      "value": "Y",
+      "description": "Set this value to N if you don't want to automatically 
create a hop configuration file (hop-config.json) when it's missing"
+    }
+  ],
+  "LocaleDefault": "en_BE",
+  "guiProperties": {
+    "FontFixedSize": "13",
+    "MaxUndo": "100",
+    "DarkMode": "Y",
+    "FontNoteSize": "13",
+    "ShowOSLook": "Y",
+    "FontFixedStyle": "0",
+    "FontNoteName": ".AppleSystemUIFont",
+    "FontFixedName": "Monospaced",
+    "FontGraphStyle": "0",
+    "FontDefaultSize": "13",
+    "GraphColorR": "255",
+    "FontGraphSize": "13",
+    "IconSize": "32",
+    "BackgroundColorB": "255",
+    "FontNoteStyle": "0",
+    "FontGraphName": ".AppleSystemUIFont",
+    "FontDefaultName": ".AppleSystemUIFont",
+    "GraphColorG": "255",
+    "UseGlobalFileBookmarks": "Y",
+    "FontDefaultStyle": "0",
+    "GraphColorB": "255",
+    "BackgroundColorR": "255",
+    "BackgroundColorG": "255",
+    "WorkflowDialogStyle": "RESIZE,MAX,MIN",
+    "LineWidth": "1",
+    "ContextDialogShowCategories": "Y"
+  },
+  "projectsConfig": {
+    "enabled": true,
+    "projectMandatory": true,
+    "environmentMandatory": true,
+    "defaultProject": "default",
+    "defaultEnvironment": null,
+    "standardParentProject": "default",
+    "standardProjectsFolder": null,
+    "projectConfigurations": [
+      {
+        "projectName": "default",
+        "projectHome": "${HOP_CONFIG_FOLDER}",
+        "configFilename": "project-config.json"
+      }
+    ],
+    "lifecycleEnvironments": [
+      {
+        "name": "dev",
+        "purpose": "Testing",
+        "projectName": "default",
+        "configurationFiles": [
+          "${PROJECT_HOME}/dev-env-config.json"
+        ]
+      }
+    ],
+    "projectLifecycles": []
+  }
+}
\ No newline at end of file
diff --git a/integration-tests/ssh/keys/it_rsa 
b/integration-tests/ssh/keys/it_rsa
new file mode 100644
index 0000000000..052134925f
--- /dev/null
+++ b/integration-tests/ssh/keys/it_rsa
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAu6+/AjE3Sjl4d0lPlUkNexCZhTI6M2LLIHIRS8IhxyxDtM9l
+/IX4bFdn0tAHNBJf4DDNT4YWatMAox7UyDL7y8RHPGDWpc74WDrjMfIsu6VVZHd/
+lmsJNgrMWvhv+KJhH2GASjAreYGI03lVt7+zxXO1oZ+B8+HnUKgFLes3F4No4FxO
+JVKlz2bVsf6E8tN+OfQypQtYSAb6rPJjsw82jlpPrZzT5vcJc9juLLPmAJBCo/4z
+QwJc7XCiNidwcH7m0iagHA9iuhCtMRczSvTFhptNoMpfuj+IzwusqNOjU/K0P1nQ
+7sCaDnOiq3aV3GU7sLqBokEhx5BMi17ynzdbdQIDAQABAoIBAAlIGrEYD/zqtKtp
+g7cFQtZoLr9oiXpLE3KKUZKmihcYeEyzyP5g/bUV6XuCcCjCE925bB3Xqrojry9h
+8fHom40rKr6wp0zR3HQ4jU3GBTJObdenFTcyGeWDSTHigV8RYK41myuQEEZVApg6
+suOAZnqIS14vzjRqYo8ZkBACRtoxvq+c4M9I/UyXz33bA79sMjB1gWDbeI1JPVk2
+7noUneu2iKe20l9LZf5t3osyBRFXxgE23sLpvty4nyoILF1qWEf/6Hx/P/md3kCQ
+Vt0ftX3lcLwV9RuFykyO66Aql79kCbJ9EC72nSzzJvnna2KbybwRBOifi6R0PF7a
+SJH84MECgYEA8gO4ICWyoTEm4yuynGWdLa/k00xOiP8kUJE19FfQuPn2D/fO+J3I
+r1eeYKEjm0si0pqGVy74dnBslL3yaelCSsN2uf/ozFO2cwSTu83OmcbnTLD96z1y
+bd92U84ujqwJ7wzXH9nGObOVUQWc/LoVmNZM7zu812amkGRrIT+rtLECgYEAxohR
+DQwzNJjKfE5VQqRwZsMdDNrDfcnPa5fN1jKjCsi5htvWskgsHhfQ8rlAzySTe32/
+lSoJSUPyw2f86aayZ5UH2mNCv7iXX9EoH3Wt0tbuU3AFO2uXAzZCodUE+pdWtvAZ
+vhzkkJJzN5PPOlikIEwwrJLfQ4Gz4QhVGP5vFAUCgYEAjLAgz39asl3yb0kt0cE4
+eCCycyr+1KENqVBg/yQ1j/KvWmUCioCe81+KED5chqBNJAT0Z6ZEhgWg+W7ahzs0
+cGXklQfxeyaG/6H/h8OCgN6iA3E4ixHzfW/UR6+qXQIh3DeorzlYBJ8jBDCxLDG4
+8FpT6xbdFpLz7SiTJobu+GECgYA5g8BHUWN8N09h16enmM/fVWMTGEVOKarndqDx
+DtZhB2mIAiQenf358dhcmQKHgAch3XolEnqCOScZKQUCA4LnsysFP4BU3nssDQHc
+q1DiJdYBYhCB+FdVXODM1VON7U33zXMHuoMUxviN/0onkwppOOlY9WusuOSNqsZM
+aVlwqQKBgFB8KjS6fVCVePQhRSUUvO17lSFGwCSZeuuHO5nM0euFk5Vn8eMzzgI+
+sn2MUvacLuY9OSf0PXkoyl2Hew7IkFIByoaPXMDuYvCTObMDaTrWucnaP7MCvOZM
+FHI5w/tU+JQi/dnbBznKti/rW4JzkPrdOKA58/QagGBzbEfV/1Wu
+-----END RSA PRIVATE KEY-----
diff --git a/integration-tests/ssh/keys/it_rsa.pub 
b/integration-tests/ssh/keys/it_rsa.pub
new file mode 100644
index 0000000000..a035d279ff
--- /dev/null
+++ b/integration-tests/ssh/keys/it_rsa.pub
@@ -0,0 +1 @@
+ssh-rsa 
AAAAB3NzaC1yc2EAAAADAQABAAABAQC7r78CMTdKOXh3SU+VSQ17EJmFMjozYssgchFLwiHHLEO0z2X8hfhsV2fS0Ac0El/gMM1PhhZq0wCjHtTIMvvLxEc8YNalzvhYOuMx8iy7pVVkd3+Wawk2Csxa+G/4omEfYYBKMCt5gYjTeVW3v7PFc7Whn4Hz4edQqAUt6zcXg2jgXE4lUqXPZtWx/oTy03459DKlC1hIBvqs8mOzDzaOWk+tnNPm9wlz2O4ss+YAkEKj/jNDAlztcKI2J3BwfubSJqAcD2K6EK0xFzNK9MWGm02gyl+6P4jPC6yo06NT8rQ/WdDuwJoOc6KrdpXcZTuwuoGiQSHHkEyLXvKfN1t1
 hop-integration-test-key (test only)
diff --git a/integration-tests/ssh/main-0001-ssh-command-password.hwf 
b/integration-tests/ssh/main-0001-ssh-command-password.hwf
new file mode 100644
index 0000000000..91f833fadc
--- /dev/null
+++ b/integration-tests/ssh/main-0001-ssh-command-password.hwf
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0001-ssh-command-password</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Run a command over SSH using password authentication and assert 
the result</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/06/02 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/06/02 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>50</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>ssh-command-password.hpl</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <filename>${PROJECT_HOME}/ssh-command-password.hpl</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <clear_rows>N</clear_rows>
+      <clear_files>N</clear_files>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <set_append_logfile>N</set_append_logfile>
+      <wait_until_finished>Y</wait_until_finished>
+      <follow_abort_remote>N</follow_abort_remote>
+      <create_parent_folder>N</create_parent_folder>
+      <run_configuration>local</run_configuration>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <parallel>N</parallel>
+      <xloc>195</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>ssh-command-password.hpl</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git a/integration-tests/ssh/main-0002-ssh-command-private-key.hwf 
b/integration-tests/ssh/main-0002-ssh-command-private-key.hwf
new file mode 100644
index 0000000000..8ba4e4b9f8
--- /dev/null
+++ b/integration-tests/ssh/main-0002-ssh-command-private-key.hwf
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+  <name>main-0002-ssh-command-private-key</name>
+  <name_sync_with_filename>Y</name_sync_with_filename>
+  <description>Run a command over SSH using private-key authentication and 
assert the result</description>
+  <extended_description/>
+  <workflow_version/>
+  <created_user>-</created_user>
+  <created_date>2026/06/02 00:00:00.000</created_date>
+  <modified_user>-</modified_user>
+  <modified_date>2026/06/02 00:00:00.000</modified_date>
+  <parameters>
+    </parameters>
+  <actions>
+    <action>
+      <name>Start</name>
+      <description/>
+      <type>SPECIAL</type>
+      <attributes/>
+      <DayOfMonth>1</DayOfMonth>
+      <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+      <hour>12</hour>
+      <intervalMinutes>60</intervalMinutes>
+      <intervalSeconds>0</intervalSeconds>
+      <minutes>0</minutes>
+      <repeat>N</repeat>
+      <schedulerType>0</schedulerType>
+      <weekDay>1</weekDay>
+      <parallel>N</parallel>
+      <xloc>50</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+    <action>
+      <name>ssh-command-private-key.hpl</name>
+      <description/>
+      <type>PIPELINE</type>
+      <attributes/>
+      <filename>${PROJECT_HOME}/ssh-command-private-key.hpl</filename>
+      <params_from_previous>N</params_from_previous>
+      <exec_per_row>N</exec_per_row>
+      <clear_rows>N</clear_rows>
+      <clear_files>N</clear_files>
+      <set_logfile>N</set_logfile>
+      <logfile/>
+      <logext/>
+      <add_date>N</add_date>
+      <add_time>N</add_time>
+      <loglevel>Basic</loglevel>
+      <set_append_logfile>N</set_append_logfile>
+      <wait_until_finished>Y</wait_until_finished>
+      <follow_abort_remote>N</follow_abort_remote>
+      <create_parent_folder>N</create_parent_folder>
+      <run_configuration>local</run_configuration>
+      <parameters>
+        <pass_all_parameters>Y</pass_all_parameters>
+      </parameters>
+      <parallel>N</parallel>
+      <xloc>195</xloc>
+      <yloc>50</yloc>
+      <attributes_hac/>
+    </action>
+  </actions>
+  <hops>
+    <hop>
+      <from>Start</from>
+      <to>ssh-command-private-key.hpl</to>
+      <enabled>Y</enabled>
+      <evaluation>Y</evaluation>
+      <unconditional>Y</unconditional>
+    </hop>
+  </hops>
+  <notepads>
+  </notepads>
+  <attributes/>
+</workflow>
diff --git 
a/integration-tests/ssh/metadata/pipeline-run-configuration/local.json 
b/integration-tests/ssh/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 0000000000..0c0d87c84d
--- /dev/null
+++ b/integration-tests/ssh/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,22 @@
+{
+  "engineRunConfiguration": {
+    "Local": {
+      "feedback_size": "50000",
+      "sample_size": "100",
+      "sample_type_in_gui": "Last",
+      "wait_time": "20",
+      "rowset_size": "10000",
+      "safe_mode": false,
+      "show_feedback": false,
+      "topo_sort": false,
+      "gather_metrics": false,
+      "transactional": false
+    }
+  },
+  "defaultSelection": true,
+  "configurationVariables": [],
+  "name": "local",
+  "description": "",
+  "dataProfile": "",
+  "executionInfoLocationName": ""
+}
\ No newline at end of file
diff --git 
a/integration-tests/ssh/metadata/workflow-run-configuration/local.json 
b/integration-tests/ssh/metadata/workflow-run-configuration/local.json
new file mode 100644
index 0000000000..1d0cf74bae
--- /dev/null
+++ b/integration-tests/ssh/metadata/workflow-run-configuration/local.json
@@ -0,0 +1,11 @@
+{
+  "engineRunConfiguration": {
+    "Local": {
+      "safe_mode": false,
+      "transactional": false
+    }
+  },
+  "defaultSelection": true,
+  "name": "local",
+  "description": "Runs your workflows locally with the standard local Hop 
workflow engine"
+}
\ No newline at end of file
diff --git a/integration-tests/ssh/project-config.json 
b/integration-tests/ssh/project-config.json
new file mode 100644
index 0000000000..6a91171e1c
--- /dev/null
+++ b/integration-tests/ssh/project-config.json
@@ -0,0 +1,13 @@
+{
+  "metadataBaseFolder" : "${PROJECT_HOME}/metadata",
+  "unitTestsBasePath" : "${PROJECT_HOME}",
+  "dataSetsCsvFolder" : "${PROJECT_HOME}/datasets",
+  "enforcingExecutionInHome" : true,
+  "config" : {
+    "variables" : [ {
+      "name" : "HOP_LICENSE_HEADER_FILE",
+      "value" : "${PROJECT_HOME}/../asf-header.txt",
+      "description" : "This will automatically serialize the ASF license 
header into pipelines and workflows in the integration test projects"
+    } ]
+  }
+}
\ No newline at end of file
diff --git a/integration-tests/ssh/ssh-command-password.hpl 
b/integration-tests/ssh/ssh-command-password.hpl
new file mode 100644
index 0000000000..c781bf89ec
--- /dev/null
+++ b/integration-tests/ssh/ssh-command-password.hpl
@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+  <info>
+    <name>ssh-command-password</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description>Runs a command over SSH (password auth) against the test SSH 
server and asserts the echoed marker comes back on stdOut.</description>
+    <pipeline_type>Normal</pipeline_type>
+    <pipeline_status>-1</pipeline_status>
+    <parameters/>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2026/06/02 00:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2026/06/02 00:00:00.000</modified_date>
+  </info>
+  <transform>
+    <name>Run SSH command</name>
+    <type>SSH</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <command>echo hop-ssh-it-marker</command>
+    <dynamicCommandField>N</dynamicCommandField>
+    <commandfieldname/>
+    <serverName>${SSH_HOSTNAME}</serverName>
+    <port>${SSH_PORT}</port>
+    <userName>${SSH_USER}</userName>
+    <password>${SSH_PASSWORD}</password>
+    <usePrivateKey>N</usePrivateKey>
+    <keyFileName/>
+    <passPhrase>Encrypted </passPhrase>
+    <stdOutFieldName>stdOut</stdOutFieldName>
+    <stdErrFieldName>stdErr</stdErrFieldName>
+    <timeOut>30</timeOut>
+    <proxyHost/>
+    <proxyPort/>
+    <proxyUsername/>
+    <proxyPassword>Encrypted </proxyPassword>
+    <attributes/>
+    <GUI>
+      <xloc>176</xloc>
+      <yloc>112</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>marker not found?</name>
+    <type>FilterRows</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <send_true_to>OK</send_true_to>
+    <send_false_to>Abort</send_false_to>
+    <compare>
+      <condition>
+        <negated>N</negated>
+        <leftvalue>stdOut</leftvalue>
+        <function>CONTAINS</function>
+        <rightvalue/>
+        <value>
+          <name>constant</name>
+          <type>String</type>
+          <text>hop-ssh-it-marker</text>
+          <length>-1</length>
+          <precision>-1</precision>
+          <isnull>N</isnull>
+          <mask/>
+        </value>
+      </condition>
+    </compare>
+    <attributes/>
+    <GUI>
+      <xloc>352</xloc>
+      <yloc>112</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>OK</name>
+    <type>Dummy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+    <GUI>
+      <xloc>528</xloc>
+      <yloc>64</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Abort</name>
+    <type>Abort</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <row_threshold>0</row_threshold>
+    <message>SSH transform did not return the expected marker on 
stdOut</message>
+    <always_log_rows>Y</always_log_rows>
+    <abort_option>ABORT_WITH_ERROR</abort_option>
+    <attributes/>
+    <GUI>
+      <xloc>528</xloc>
+      <yloc>176</yloc>
+    </GUI>
+  </transform>
+  <order>
+    <hop>
+      <from>Run SSH command</from>
+      <to>marker not found?</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>marker not found?</from>
+      <to>OK</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>marker not found?</from>
+      <to>Abort</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <notepads/>
+  <attributes/>
+  <transform_error_handling/>
+</pipeline>
diff --git a/integration-tests/ssh/ssh-command-private-key.hpl 
b/integration-tests/ssh/ssh-command-private-key.hpl
new file mode 100644
index 0000000000..9634617b78
--- /dev/null
+++ b/integration-tests/ssh/ssh-command-private-key.hpl
@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+  <info>
+    <name>ssh-command-private-key</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <description>Runs a command over SSH using private-key authentication 
against the test SSH server and asserts the echoed marker comes back on 
stdOut.</description>
+    <pipeline_type>Normal</pipeline_type>
+    <pipeline_status>-1</pipeline_status>
+    <parameters/>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <created_user>-</created_user>
+    <created_date>2026/06/02 00:00:00.000</created_date>
+    <modified_user>-</modified_user>
+    <modified_date>2026/06/02 00:00:00.000</modified_date>
+  </info>
+  <transform>
+    <name>Run SSH command</name>
+    <type>SSH</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <command>echo hop-ssh-it-marker</command>
+    <dynamicCommandField>N</dynamicCommandField>
+    <commandfieldname/>
+    <serverName>${SSH_HOSTNAME}</serverName>
+    <port>${SSH_PORT}</port>
+    <userName>${SSH_USER}</userName>
+    <password>Encrypted </password>
+    <usePrivateKey>Y</usePrivateKey>
+    <keyFileName>${PROJECT_HOME}/keys/it_rsa</keyFileName>
+    <passPhrase>Encrypted </passPhrase>
+    <stdOutFieldName>stdOut</stdOutFieldName>
+    <stdErrFieldName>stdErr</stdErrFieldName>
+    <timeOut>30</timeOut>
+    <proxyHost/>
+    <proxyPort/>
+    <proxyUsername/>
+    <proxyPassword>Encrypted </proxyPassword>
+    <attributes/>
+    <GUI>
+      <xloc>176</xloc>
+      <yloc>112</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>marker not found?</name>
+    <type>FilterRows</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <send_true_to>OK</send_true_to>
+    <send_false_to>Abort</send_false_to>
+    <compare>
+      <condition>
+        <negated>N</negated>
+        <leftvalue>stdOut</leftvalue>
+        <function>CONTAINS</function>
+        <rightvalue/>
+        <value>
+          <name>constant</name>
+          <type>String</type>
+          <text>hop-ssh-it-marker</text>
+          <length>-1</length>
+          <precision>-1</precision>
+          <isnull>N</isnull>
+          <mask/>
+        </value>
+      </condition>
+    </compare>
+    <attributes/>
+    <GUI>
+      <xloc>352</xloc>
+      <yloc>112</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>OK</name>
+    <type>Dummy</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+    <GUI>
+      <xloc>528</xloc>
+      <yloc>64</yloc>
+    </GUI>
+  </transform>
+  <transform>
+    <name>Abort</name>
+    <type>Abort</type>
+    <description/>
+    <distribute>Y</distribute>
+    <custom_distribution/>
+    <copies>1</copies>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <row_threshold>0</row_threshold>
+    <message>SSH transform (private key) did not return the expected marker on 
stdOut</message>
+    <always_log_rows>Y</always_log_rows>
+    <abort_option>ABORT_WITH_ERROR</abort_option>
+    <attributes/>
+    <GUI>
+      <xloc>528</xloc>
+      <yloc>176</yloc>
+    </GUI>
+  </transform>
+  <order>
+    <hop>
+      <from>Run SSH command</from>
+      <to>marker not found?</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>marker not found?</from>
+      <to>OK</to>
+      <enabled>Y</enabled>
+    </hop>
+    <hop>
+      <from>marker not found?</from>
+      <to>Abort</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <notepads/>
+  <attributes/>
+  <transform_error_handling/>
+</pipeline>
diff --git a/plugins/transforms/ssh/pom.xml b/plugins/transforms/ssh/pom.xml
index 2c15e4faa5..69f85e6c73 100644
--- a/plugins/transforms/ssh/pom.xml
+++ b/plugins/transforms/ssh/pom.xml
@@ -29,15 +29,28 @@
     <packaging>jar</packaging>
     <name>Hop Plugins Transforms SSH</name>
 
-    <properties>
-        <sshlib.version>2.2.43</sshlib.version>
-    </properties>
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.apache.hop</groupId>
+                <artifactId>hop-libs</artifactId>
+                <version>${project.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
 
     <dependencies>
         <dependency>
-            <groupId>org.connectbot</groupId>
-            <artifactId>sshlib</artifactId>
-            <version>${sshlib.version}</version>
+            <groupId>com.github.mwiede</groupId>
+            <artifactId>jsch</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.sshd</groupId>
+            <artifactId>sshd-core</artifactId>
+            <version>2.16.0</version>
+            <scope>test</scope>
         </dependency>
     </dependencies>
 
diff --git a/plugins/transforms/ssh/src/assembly/assembly.xml 
b/plugins/transforms/ssh/src/assembly/assembly.xml
index e134f09834..12819fb118 100644
--- a/plugins/transforms/ssh/src/assembly/assembly.xml
+++ b/plugins/transforms/ssh/src/assembly/assembly.xml
@@ -48,9 +48,10 @@
         </dependencySet>
         <dependencySet>
             <includes>
-                <include>org.connectbot:sshlib:jar</include>
+                <include>com.github.mwiede:jsch:jar</include>
             </includes>
             <outputDirectory>plugins/transforms/ssh/lib</outputDirectory>
+            <useProjectArtifact>false</useProjectArtifact>
         </dependencySet>
     </dependencySets>
-</assembly>
\ No newline at end of file
+</assembly>
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SessionResult.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SessionResult.java
index 7777b0b60d..d24f50618e 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SessionResult.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SessionResult.java
@@ -17,106 +17,104 @@
 
 package org.apache.hop.pipeline.transforms.ssh;
 
-import com.trilead.ssh2.Session;
-import java.io.BufferedReader;
+import com.jcraft.jsch.ChannelExec;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import java.io.IOException;
 import java.io.InputStream;
-import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.hop.core.exception.HopException;
-import org.apache.hop.core.util.Utils;
 
 public class SessionResult {
 
-  private String stdOut;
-  private String stdErr;
-  private boolean stdErrorType;
+  private static final int CHANNEL_POLL_MS = 50;
+  private static final int CHANNEL_MAX_WAIT_MS = 120_000;
 
-  public SessionResult(Session session) throws HopException {
-    readStd(session);
+  @Getter @Setter private String stdOut;
+  @Getter private String stdErr;
+  @Getter @Setter private boolean stdErrorType;
+
+  public static SessionResult executeCommand(Session session, String command) 
throws HopException {
+    ChannelExec channel = null;
+    try {
+      channel = (ChannelExec) session.openChannel("exec");
+      channel.setCommand(command);
+      channel.connect();
+      SessionResult result = new SessionResult();
+      result.readFromChannel(channel);
+      return result;
+    } catch (JSchException e) {
+      throw new HopException(e);
+    } finally {
+      if (channel != null) {
+        channel.disconnect();
+      }
+    }
   }
 
   private void setStdErr(String value) {
     this.stdErr = value;
-    if (!Utils.isEmpty(getStdErr())) {
-      setStdTypeErr(true);
+    if (stdErr != null && !stdErr.isEmpty()) {
+      setStdErrorType(true);
     }
   }
 
-  public String getStdErr() {
-    return this.stdErr;
-  }
-
   public String getStd() {
-    return getStdOut() + getStdErr();
-  }
-
-  private void setStdOut(String value) {
-    this.stdOut = value;
-  }
-
-  public String getStdOut() {
-    return this.stdOut;
-  }
-
-  private void setStdTypeErr(boolean value) {
-    this.stdErrorType = value;
+    return (getStdOut() == null ? "" : getStdOut()) + (getStdErr() == null ? 
"" : getStdErr());
   }
 
-  public boolean isStdTypeErr() {
-    return this.stdErrorType;
-  }
-
-  private void readStd(Session session) throws HopException {
-    InputStream isOut = null;
-    InputStream isErr = null;
+  private void readFromChannel(ChannelExec channel) throws HopException {
     try {
-      isOut = session.getStdout();
-      isErr = session.getStderr();
-
-      setStdOut(readInputStream(isOut));
-      setStdErr(readInputStream(isErr));
-
-    } catch (Exception e) {
-      throw new HopException(e);
-    } finally {
-      try {
-        if (isOut != null) {
-          isOut.close();
-        }
-        if (isErr != null) {
-          isErr.close();
+      InputStream isOut = channel.getInputStream();
+      InputStream isErr = channel.getErrStream();
+      byte[] buffer = new byte[8192];
+      StringBuilder stdout = new StringBuilder();
+      StringBuilder stderr = new StringBuilder();
+
+      long deadline = System.currentTimeMillis() + CHANNEL_MAX_WAIT_MS;
+      while (true) {
+        appendAvailable(isOut, buffer, stdout);
+        appendAvailable(isErr, buffer, stderr);
+        if (channel.isClosed()) {
+          if (isStreamDrained(isOut) && isStreamDrained(isErr)) {
+            break;
+          }
+        } else if (System.currentTimeMillis() > deadline) {
+          throw new HopException("Timed out waiting for SSH command to 
complete");
+        } else {
+          try {
+            Thread.sleep(CHANNEL_POLL_MS);
+          } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new HopException(e);
+          }
         }
-      } catch (Exception e) {
-        /* Ignore */
       }
+
+      setStdOut(stdout.toString());
+      setStdErr(stderr.toString());
+    } catch (IOException e) {
+      throw new HopException(e);
     }
   }
 
-  private String readInputStream(InputStream std) throws HopException {
-    BufferedReader br = null;
-    try {
-      br = new BufferedReader(new InputStreamReader(std));
-
-      String line = "";
-      StringBuilder stringStdout = new StringBuilder();
-
-      if ((line = br.readLine()) != null) {
-        stringStdout.append(line);
-      }
-      while ((line = br.readLine()) != null) {
-        stringStdout.append("\n" + line);
-      }
-
-      return stringStdout.toString();
-    } catch (Exception e) {
-      throw new HopException(e);
-    } finally {
-      try {
-        if (br != null) {
-          br.close();
-        }
-      } catch (Exception e) {
-        /* Ignore */
+  private static void appendAvailable(InputStream in, byte[] buffer, 
StringBuilder target)
+      throws IOException {
+    if (in == null) {
+      return;
+    }
+    while (in.available() > 0) {
+      int read = in.read(buffer, 0, buffer.length);
+      if (read < 0) {
+        break;
       }
+      target.append(new String(buffer, 0, read, StandardCharsets.UTF_8));
     }
   }
+
+  private boolean isStreamDrained(InputStream in) throws IOException {
+    return in == null || in.available() == 0;
+  }
 }
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/Ssh.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/Ssh.java
index 25e6256c38..c5218843f1 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/Ssh.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/Ssh.java
@@ -17,7 +17,6 @@
 
 package org.apache.hop.pipeline.transforms.ssh;
 
-import com.trilead.ssh2.Session;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.RowMeta;
@@ -44,7 +43,6 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
 
   @Override
   public boolean processRow() throws HopException {
-
     Object[] row;
     if (meta.isDynamicCommandField()) {
       row = getRow();
@@ -76,7 +74,8 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
       }
     } else {
       if (!data.wroteOneRow) {
-        row = new Object[] {}; // empty row
+        // empty row
+        row = new Object[] {};
         incrementLinesRead();
         data.wroteOneRow = true;
         if (first) {
@@ -101,12 +100,12 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
     }
     // Reserve room
     Object[] rowData = new Object[data.nrOutputFields];
-    for (int i = 0; i < data.nrInputFields; i++) {
-      rowData[i] = row[i]; // no data is changed, clone is not needed here.
+    // no data is changed, clone is not needed here.
+    if (data.nrInputFields >= 0) {
+      System.arraycopy(row, 0, rowData, 0, data.nrInputFields);
     }
     int index = data.nrInputFields;
 
-    Session session = null;
     try {
       if (meta.isDynamicCommandField()) {
         // get commands
@@ -116,8 +115,6 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
         }
       }
 
-      // Open a session
-      session = data.conn.openSession();
       if (isDebug()) {
         logDebug(BaseMessages.getString(PKG, "SSH.Log.SessionOpened"));
       }
@@ -126,10 +123,7 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
       if (isDetailed()) {
         logDetailed(BaseMessages.getString(PKG, "SSH.Log.RunningCommand", 
data.commands));
       }
-      session.execCommand(data.commands);
-
-      // Read Stdout, Sterr and exitStatus
-      SessionResult sessionresult = new SessionResult(session);
+      SessionResult sessionresult = SessionResult.executeCommand(data.session, 
data.commands);
       if (isDebug()) {
         logDebug(
             BaseMessages.getString(
@@ -145,7 +139,7 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
 
       if (!Utils.isEmpty(data.stdTypeField)) {
         // Add stdtype to output
-        rowData[index++] = sessionresult.isStdTypeErr();
+        rowData[index] = sessionresult.isStdErrorType();
       }
 
       if (isRowLevel()) {
@@ -160,30 +154,23 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
         logDetailed(BaseMessages.getString(PKG, "SSH.LineNumber", "" + 
getLinesRead()));
       }
     } catch (Exception e) {
-
-      boolean sendToErrorRow = false;
       String errorMessage = null;
 
       if (getTransformMeta().isDoingErrorHandling()) {
-        sendToErrorRow = true;
         errorMessage = e.toString();
       } else {
         logError(BaseMessages.getString(PKG, "SSH.ErrorInTransformRunning") + 
e.getMessage());
         setErrors(1);
         stopAll();
-        setOutputDone(); // signal end to receiver(s)
+        // signal end to receiver(s)
+        setOutputDone();
         return false;
       }
-      if (sendToErrorRow) {
-        // Simply add this row to the error row
-        putError(getInputRowMeta(), row, 1, errorMessage, null, "SSH001");
-      }
+      // Simply add this row to the error row
+      putError(getInputRowMeta(), row, 1, errorMessage, null, "SSH001");
     } finally {
-      if (session != null) {
-        session.close();
-        if (isDebug()) {
-          logDebug(BaseMessages.getString(PKG, "SSH.Log.SessionClosed"));
-        }
+      if (isDebug()) {
+        logDebug(BaseMessages.getString(PKG, "SSH.Log.SessionClosed"));
       }
     }
 
@@ -216,7 +203,7 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
 
       try {
         // Open connection
-        data.conn = SshData.openConnection(this, meta);
+        data.session = SshData.openConnection(this, meta);
 
         if (isDebug()) {
           logDebug(BaseMessages.getString(PKG, "SSH.Log.ConnectionOpened"));
@@ -231,4 +218,13 @@ public class Ssh extends BaseTransform<SshMeta, SshData> {
     }
     return false;
   }
+
+  @Override
+  public void dispose() {
+    if (data.session != null && data.session.isConnected()) {
+      data.session.disconnect();
+      data.session = null;
+    }
+    super.dispose();
+  }
 }
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshData.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshData.java
index 23f6212119..61433b0cfc 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshData.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshData.java
@@ -17,14 +17,13 @@
 
 package org.apache.hop.pipeline.transforms.ssh;
 
-import com.trilead.ssh2.Connection;
-import com.trilead.ssh2.HTTPProxyData;
-import java.io.CharArrayWriter;
-import java.io.InputStream;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.ProxyHTTP;
+import com.jcraft.jsch.Session;
 import java.nio.charset.StandardCharsets;
-import org.apache.commons.io.IOUtils;
+import java.util.Properties;
 import org.apache.commons.lang3.StringUtils;
-import org.apache.commons.vfs2.FileContent;
 import org.apache.commons.vfs2.FileObject;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.encryption.Encr;
@@ -40,8 +39,9 @@ import org.apache.hop.pipeline.transform.ITransformData;
 @SuppressWarnings("java:S1104")
 public class SshData extends BaseTransformData implements ITransformData {
   private static final Class<?> PKG = SshMeta.class;
+
   public int indexOfCommand;
-  public Connection conn;
+  public Session session;
   public boolean wroteOneRow;
   public String commands;
   public int nrInputFields;
@@ -56,17 +56,15 @@ public class SshData extends BaseTransformData implements 
ITransformData {
   public SshData() {
     super();
     this.indexOfCommand = -1;
-    this.conn = null;
+    this.session = null;
     this.wroteOneRow = false;
     this.commands = null;
     this.stdOutField = null;
     this.stdTypeField = null;
   }
 
-  public static Connection openConnection(IVariables variables, SshMeta meta) 
throws HopException {
-    Connection connection = null;
-    char[] content = null;
-    boolean isAuthenticated;
+  public static Session openConnection(IVariables variables, SshMeta meta) 
throws HopException {
+    Session session = null;
 
     String hostname = variables.resolve(meta.getServerName());
     int port = Const.toInt(variables.resolve(meta.getPort()), 22);
@@ -77,80 +75,91 @@ public class SshData extends BaseTransformData implements 
ITransformData {
         
Encr.decryptPasswordOptionallyEncrypted(variables.resolve(meta.getPassPhrase()));
 
     try {
-      // perform some checks
+      JSch jsch = new JSch();
+
       if (meta.isUsePrivateKey()) {
         String keyFilename = variables.resolve(meta.getKeyFileName());
         if (StringUtils.isEmpty(keyFilename)) {
           throw new HopException(BaseMessages.getString(PKG, 
"SSH.Error.PrivateKeyFileMissing"));
         }
         FileObject keyFileObject = HopVfs.getFileObject(keyFilename, 
variables);
-
         if (!keyFileObject.exists()) {
           throw new HopException(
               BaseMessages.getString(PKG, "SSH.Error.PrivateKeyNotExist", 
keyFilename));
         }
-
-        FileContent keyFileContent = keyFileObject.getContent();
-
-        CharArrayWriter charArrayWriter = new CharArrayWriter((int) 
keyFileContent.getSize());
-
-        try (InputStream in = keyFileContent.getInputStream()) {
-          IOUtils.copy(in, charArrayWriter, StandardCharsets.UTF_8);
+        if (keyFileObject.getName().getURI().startsWith("file:")) {
+          String localKeyPath = HopVfs.getFilename(keyFileObject);
+          if (Utils.isEmpty(passPhrase)) {
+            jsch.addIdentity(localKeyPath);
+          } else {
+            jsch.addIdentity(localKeyPath, passPhrase);
+          }
+        } else {
+          byte[] keyBytes = keyFileObject.getContent().getByteArray();
+          byte[] passphraseBytes =
+              Utils.isEmpty(passPhrase) ? new byte[0] : 
passPhrase.getBytes(StandardCharsets.UTF_8);
+          jsch.addIdentity(username, keyBytes, null, passphraseBytes);
         }
+      }
+
+      session = jsch.getSession(username, hostname, port);
 
-        content = charArrayWriter.toCharArray();
+      if (!meta.isUsePrivateKey() && !Utils.isEmpty(password)) {
+        session.setPassword(password);
       }
 
-      // Create a new connection
-      connection = new Connection(hostname, port);
+      Properties config = new Properties();
+      config.put("StrictHostKeyChecking", "no");
+      config.put("PreferredAuthentications", 
"publickey,keyboard-interactive,password");
+      session.setConfig(config);
 
       String proxyHost = variables.resolve(meta.getProxyHost());
-      int proxyPort = Const.toInt(variables.resolve(meta.getProxyPort()), 23);
+      int proxyPort = Const.toInt(variables.resolve(meta.getProxyPort()), 80);
       String proxyUsername = variables.resolve(meta.getProxyUsername());
       String proxyPassword =
           
Encr.decryptPasswordOptionallyEncrypted(variables.resolve(meta.getProxyPassword()));
 
-      /* We want to connect through a HTTP proxy */
       if (!Utils.isEmpty(proxyHost)) {
-        /* Now connect */
-        // if the proxy requires basic authentication:
+        ProxyHTTP proxy = new ProxyHTTP(proxyHost + ":" + proxyPort);
         if (!Utils.isEmpty(proxyUsername)) {
-          connection.setProxyData(
-              new HTTPProxyData(proxyHost, proxyPort, proxyUsername, 
proxyPassword));
-        } else {
-          connection.setProxyData(new HTTPProxyData(proxyHost, proxyPort));
+          proxy.setUserPasswd(proxyUsername, proxyPassword);
         }
+        session.setProxy(proxy);
       }
 
       int timeOut = Const.toInt(variables.resolve(meta.getTimeOut()), 0);
-
-      // and connect
       if (timeOut == 0) {
-        connection.connect();
+        session.connect();
       } else {
-        connection.connect(null, 0, timeOut * 1000);
+        session.connect(timeOut * 1000);
       }
 
-      // authenticate
-      if (meta.isUsePrivateKey()) {
-        isAuthenticated =
-            connection.authenticateWithPublicKey(username, content, 
variables.resolve(passPhrase));
-      } else {
-        isAuthenticated = connection.authenticateWithPassword(username, 
password);
-      }
-      if (!isAuthenticated) {
+      if (!session.isConnected()) {
         throw new HopException(
             BaseMessages.getString(PKG, "SSH.Error.AuthenticationFailed", 
username));
       }
+    } catch (JSchException e) {
+      if (session != null) {
+        session.disconnect();
+      }
+      if (isAuthFailure(e)) {
+        throw new HopException(
+            BaseMessages.getString(PKG, "SSH.Error.AuthenticationFailed", 
username), e);
+      }
+      throw new HopException(
+          BaseMessages.getString(PKG, "SSH.Error.ErrorConnecting", hostname, 
username), e);
     } catch (Exception e) {
-      // Something wrong happened
-      // do not forget to disconnect if connected
-      if (connection != null) {
-        connection.close();
+      if (session != null) {
+        session.disconnect();
       }
       throw new HopException(
           BaseMessages.getString(PKG, "SSH.Error.ErrorConnecting", hostname, 
username), e);
     }
-    return connection;
+    return session;
+  }
+
+  private static boolean isAuthFailure(JSchException e) {
+    String message = e.getMessage();
+    return message != null && message.toLowerCase().contains("auth fail");
   }
 }
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshDialog.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshDialog.java
index d47575fc84..1fbb665b25 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshDialog.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshDialog.java
@@ -17,7 +17,7 @@
 
 package org.apache.hop.pipeline.transforms.ssh;
 
-import com.trilead.ssh2.Connection;
+import com.jcraft.jsch.Session;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.Props;
 import org.apache.hop.core.exception.HopException;
@@ -714,20 +714,20 @@ public class SshDialog extends BaseTransformDialog {
   private void test() {
     Exception exception = null;
     String errMsg = null;
-    Connection connection = null;
+    Session session = null;
 
     SshMeta meta = new SshMeta();
     getInfo(meta);
 
     try {
-      connection = SshData.openConnection(variables, meta);
+      session = SshData.openConnection(variables, meta);
     } catch (Exception e) {
       exception = e;
       errMsg = e.getMessage();
     } finally {
-      if (connection != null) {
+      if (session != null) {
         try {
-          connection.close();
+          session.disconnect();
         } catch (Exception e) {
           /* Ignore */
         }
diff --git 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshMeta.java
 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshMeta.java
index 428a86cbbd..a276bddbc8 100644
--- 
a/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshMeta.java
+++ 
b/plugins/transforms/ssh/src/main/java/org/apache/hop/pipeline/transforms/ssh/SshMeta.java
@@ -18,6 +18,8 @@
 package org.apache.hop.pipeline.transforms.ssh;
 
 import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.hop.core.CheckResult;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.annotations.Transform;
@@ -36,6 +38,8 @@ import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.BaseTransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 
+@Getter
+@Setter
 @Transform(
     id = "SSH",
     image = "ssh.svg",
@@ -199,140 +203,4 @@ public class SshMeta extends BaseTransformMeta<Ssh, 
SshData> {
   public boolean supportsErrorHandling() {
     return true;
   }
-
-  public String getCommand() {
-    return command;
-  }
-
-  public void setCommand(String command) {
-    this.command = command;
-  }
-
-  public boolean isDynamicCommandField() {
-    return dynamicCommandField;
-  }
-
-  public void setDynamicCommandField(boolean dynamicCommandField) {
-    this.dynamicCommandField = dynamicCommandField;
-  }
-
-  public String getCommandFieldName() {
-    return commandFieldName;
-  }
-
-  public void setCommandFieldName(String commandFieldName) {
-    this.commandFieldName = commandFieldName;
-  }
-
-  public String getServerName() {
-    return serverName;
-  }
-
-  public void setServerName(String serverName) {
-    this.serverName = serverName;
-  }
-
-  public String getPort() {
-    return port;
-  }
-
-  public void setPort(String port) {
-    this.port = port;
-  }
-
-  public String getUserName() {
-    return userName;
-  }
-
-  public void setUserName(String userName) {
-    this.userName = userName;
-  }
-
-  public String getPassword() {
-    return password;
-  }
-
-  public void setPassword(String password) {
-    this.password = password;
-  }
-
-  public boolean isUsePrivateKey() {
-    return usePrivateKey;
-  }
-
-  public void setUsePrivateKey(boolean usePrivateKey) {
-    this.usePrivateKey = usePrivateKey;
-  }
-
-  public String getKeyFileName() {
-    return keyFileName;
-  }
-
-  public void setKeyFileName(String keyFileName) {
-    this.keyFileName = keyFileName;
-  }
-
-  public String getPassPhrase() {
-    return passPhrase;
-  }
-
-  public void setPassPhrase(String passPhrase) {
-    this.passPhrase = passPhrase;
-  }
-
-  public String getStdOutFieldName() {
-    return stdOutFieldName;
-  }
-
-  public void setStdOutFieldName(String stdOutFieldName) {
-    this.stdOutFieldName = stdOutFieldName;
-  }
-
-  public String getStdErrFieldName() {
-    return stdErrFieldName;
-  }
-
-  public void setStdErrFieldName(String stdErrFieldName) {
-    this.stdErrFieldName = stdErrFieldName;
-  }
-
-  public String getTimeOut() {
-    return timeOut;
-  }
-
-  public void setTimeOut(String timeOut) {
-    this.timeOut = timeOut;
-  }
-
-  public String getProxyHost() {
-    return proxyHost;
-  }
-
-  public void setProxyHost(String proxyHost) {
-    this.proxyHost = proxyHost;
-  }
-
-  public String getProxyPort() {
-    return proxyPort;
-  }
-
-  public void setProxyPort(String proxyPort) {
-    this.proxyPort = proxyPort;
-  }
-
-  public String getProxyUsername() {
-    return proxyUsername;
-  }
-
-  public void setProxyUsername(String proxyUsername) {
-    this.proxyUsername = proxyUsername;
-  }
-
-  public String getProxyPassword() {
-    return proxyPassword;
-  }
-
-  public void setProxyPassword(String proxyPassword) {
-    this.proxyPassword = proxyPassword;
-  }
 }
diff --git 
a/plugins/transforms/ssh/src/main/samples/transforms/ssh-password.hpl 
b/plugins/transforms/ssh/src/main/samples/transforms/ssh-password.hpl
new file mode 100644
index 0000000000..836d3de978
--- /dev/null
+++ b/plugins/transforms/ssh/src/main/samples/transforms/ssh-password.hpl
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+  <info>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <pipeline_type>Normal</pipeline_type>
+    <pipeline_status>-1</pipeline_status>
+    <parameters/>
+    <name>New pipeline</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <created_user>-</created_user>
+    <modified_user>-</modified_user>
+    <created_date>2026/05/30 21:21:57.866</created_date>
+    <modified_date>2026/05/30 21:21:57.866</modified_date>
+  </info>
+  <transform>
+    <type>WriteToLog</type>
+    <name>Write to log</name>
+    <displayHeader>Y</displayHeader>
+    <limitRows>N</limitRows>
+    <limitRowsNumber>0</limitRowsNumber>
+    <loglevel>Basic</loglevel>
+    <fields/>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>256</xloc>
+      <yloc>112</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>SSH</type>
+    <name>Run SSH commands</name>
+    <command>mkdir -p /opt/soft/ssh &amp;&amp; echo "hop-ssh-test $(date 
'+%Y-%m-%d %H:%M:%S')" > /opt/soft/ssh/hop-test.txt &amp;&amp; ls -la 
/opt/soft/ssh/hop-test.txt</command>
+    <dynamicCommandField>N</dynamicCommandField>
+    <commandfieldname/>
+    <serverName>192.168.204.131</serverName>
+    <port>22</port>
+    <userName>root</userName>
+    <password>Encrypted 2be98afc86aa7f2e4cb79ff228dc6fa8c</password>
+    <usePrivateKey>N</usePrivateKey>
+    <keyFileName/>
+    <passPhrase>Encrypted </passPhrase>
+    <stdOutFieldName>stdOut</stdOutFieldName>
+    <stdErrFieldName>stdErr</stdErrFieldName>
+    <timeOut>30</timeOut>
+    <proxyHost/>
+    <proxyPort/>
+    <proxyUsername/>
+    <proxyPassword>Encrypted </proxyPassword>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>112</xloc>
+      <yloc>112</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <order>
+    <hop>
+      <from>Run SSH commands</from>
+      <to>Write to log</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <notepads/>
+  <attributes/>
+  <transform_error_handling/>
+</pipeline>
diff --git a/plugins/transforms/ssh/src/main/samples/transforms/ssh-pri-key.hpl 
b/plugins/transforms/ssh/src/main/samples/transforms/ssh-pri-key.hpl
new file mode 100644
index 0000000000..29fa11ba1a
--- /dev/null
+++ b/plugins/transforms/ssh/src/main/samples/transforms/ssh-pri-key.hpl
@@ -0,0 +1,97 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<pipeline>
+  <info>
+    <capture_transform_performance>N</capture_transform_performance>
+    
<transform_performance_capturing_delay>1000</transform_performance_capturing_delay>
+    
<transform_performance_capturing_size_limit>100</transform_performance_capturing_size_limit>
+    <pipeline_type>Normal</pipeline_type>
+    <pipeline_status>-1</pipeline_status>
+    <parameters/>
+    <name>New pipeline</name>
+    <name_sync_with_filename>Y</name_sync_with_filename>
+    <created_user>-</created_user>
+    <modified_user>-</modified_user>
+    <created_date>2026/05/30 21:19:10.506</created_date>
+    <modified_date>2026/05/30 21:19:10.506</modified_date>
+  </info>
+  <transform>
+    <type>WriteToLog</type>
+    <name>Write to log</name>
+    <displayHeader>Y</displayHeader>
+    <limitRows>N</limitRows>
+    <limitRowsNumber>0</limitRowsNumber>
+    <loglevel>Basic</loglevel>
+    <fields/>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>272</xloc>
+      <yloc>80</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <transform>
+    <type>SSH</type>
+    <name>Run SSH commands</name>
+    <command>mkdir -p /opt/soft/ssh &amp;&amp; echo "hop-ssh-test $(date 
'+%Y-%m-%d %H:%M:%S')" > /opt/soft/ssh/hop-test.txt &amp;&amp; ls -la 
/opt/soft/ssh/hop-test.txt</command>
+    <dynamicCommandField>N</dynamicCommandField>
+    <commandfieldname/>
+    <serverName>192.168.204.131</serverName>
+    <port>22</port>
+    <userName>root</userName>
+    <password>Encrypted </password>
+    <usePrivateKey>Y</usePrivateKey>
+    <keyFileName>D:\tmp\hopspace\file\hop-gui</keyFileName>
+    <passPhrase>Encrypted 2be98afc86aa7f2e4cb79ce78db9ea3d5</passPhrase>
+    <stdOutFieldName>stdOut</stdOutFieldName>
+    <stdErrFieldName>stdErr</stdErrFieldName>
+    <timeOut>30</timeOut>
+    <proxyHost/>
+    <proxyPort/>
+    <proxyUsername/>
+    <proxyPassword>Encrypted </proxyPassword>
+    <distribute>Y</distribute>
+    <copies>1</copies>
+    <GUI>
+      <xloc>128</xloc>
+      <yloc>80</yloc>
+    </GUI>
+    <partitioning>
+      <method>none</method>
+      <schema_name/>
+    </partitioning>
+    <attributes/>
+  </transform>
+  <order>
+    <hop>
+      <from>Run SSH commands</from>
+      <to>Write to log</to>
+      <enabled>Y</enabled>
+    </hop>
+  </order>
+  <notepads/>
+  <attributes/>
+  <transform_error_handling/>
+</pipeline>
diff --git 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SessionResultTest.java
 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SessionResultTest.java
new file mode 100644
index 0000000000..8b03971cd5
--- /dev/null
+++ 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SessionResultTest.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.transforms.ssh;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.jcraft.jsch.ChannelExec;
+import com.jcraft.jsch.JSchException;
+import com.jcraft.jsch.Session;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.apache.hop.core.exception.HopException;
+import org.junit.jupiter.api.Test;
+
+class SessionResultTest {
+
+  @Test
+  void getStdWithStdoutOnly() {
+    SessionResult result = new SessionResult();
+    result.setStdOut("out");
+    assertEquals("out", result.getStd());
+    assertFalse(result.isStdErrorType());
+  }
+
+  @Test
+  void executeCommandReadsStdoutAndStderrFromChannel() throws Exception {
+    Session session = mock(Session.class);
+    ChannelExec channel = mock(ChannelExec.class);
+    when(session.openChannel("exec")).thenReturn(channel);
+    doNothing().when(channel).connect();
+    when(channel.getInputStream()).thenReturn(stream("line1\nline2\n"));
+    when(channel.getErrStream()).thenReturn(stream("oops\n"));
+    when(channel.isClosed()).thenReturn(true);
+
+    SessionResult result = SessionResult.executeCommand(session, "echo test");
+
+    assertEquals("line1\nline2\n", result.getStdOut());
+    assertEquals("oops\n", result.getStdErr());
+    assertTrue(result.isStdErrorType());
+    assertEquals("line1\nline2\noops\n", result.getStd());
+    verify(channel).setCommand("echo test");
+    verify(channel).disconnect();
+  }
+
+  @Test
+  void executeCommandWrapsJSchException() throws Exception {
+    Session session = mock(Session.class);
+    when(session.openChannel("exec")).thenThrow(new JSchException("channel 
failed"));
+
+    HopException ex =
+        assertThrows(HopException.class, () -> 
SessionResult.executeCommand(session, "id"));
+
+    assertInstanceOf(JSchException.class, ex.getCause());
+  }
+
+  private static InputStream stream(String text) {
+    return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
+  }
+}
diff --git 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshDataTest.java
 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshDataTest.java
new file mode 100644
index 0000000000..4df8d4a713
--- /dev/null
+++ 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshDataTest.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.transforms.ssh;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.jcraft.jsch.Session;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.security.PublicKey;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.sshd.server.SshServer;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Unit test for {@link SshData} */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class SshDataTest extends SshTestSupport {
+  @TempDir static java.nio.file.Path tempDir;
+  private static SshServer server;
+  private static int sshPort;
+  private static java.nio.file.Path privateKeyPath;
+
+  @BeforeAll
+  static void beforeAll() throws Exception {
+    PublicKey authorizedPublicKey = null;
+    if (isSshKeygenAvailable()) {
+      String keyName = "ssh_data";
+      privateKeyPath = generateRsaKeyPair(tempDir, keyName);
+      authorizedPublicKey = loadPublicKeyFromPubFile(Path.of(privateKeyPath + 
".pub"));
+    }
+    server = startPasswordSshServer(authorizedPublicKey);
+    sshPort = server.getPort();
+  }
+
+  @Test
+  void openConnectionWithPasswordAuthenticates() throws Exception {
+    SshMeta meta = SshTestSupport.passwordMeta(sshPort);
+
+    Session session = SshData.openConnection(SshTestSupport.localVariables(), 
meta);
+
+    assertNotNull(session);
+    assertTrue(session.isConnected());
+    session.disconnect();
+  }
+
+  @Test
+  void openConnectionWithPrivateKeyAuthenticates() throws Exception {
+    Assumptions.assumeTrue(
+        privateKeyPath != null, "ssh-keygen is required for private key 
authentication tests");
+    SshMeta meta =
+        SshTestSupport.privateKeyMeta(sshPort, 
privateKeyPath.toAbsolutePath().toString(), null);
+    Session session = SshData.openConnection(SshTestSupport.localVariables(), 
meta);
+
+    assertNotNull(session);
+    assertTrue(session.isConnected());
+    session.disconnect();
+  }
+
+  @Test
+  void openConnectionMissingPrivateKeyFileThrows() {
+    SshMeta meta = SshTestSupport.passwordMeta(sshPort);
+    meta.setUsePrivateKey(true);
+    meta.setKeyFileName(null);
+
+    HopException ex =
+        assertThrows(
+            HopException.class,
+            () -> SshData.openConnection(SshTestSupport.localVariables(), 
meta));
+
+    assertTrue(ex.getMessage().contains("PrivateKeyFileMissing") || 
!ex.getMessage().isEmpty());
+  }
+
+  @Test
+  void openConnectionNonExistentPrivateKeyThrows() {
+    SshMeta meta = SshTestSupport.passwordMeta(sshPort);
+    meta.setUsePrivateKey(true);
+    meta.setKeyFileName(tempDir.resolve("missing-key.pem").toUri().toString());
+
+    assertThrows(
+        HopException.class, () -> 
SshData.openConnection(SshTestSupport.localVariables(), meta));
+  }
+
+  @Test
+  void openConnectionWrongPasswordThrows() {
+    SshMeta meta = SshTestSupport.passwordMeta(sshPort);
+    meta.setPassword("definitely-wrong-password");
+    meta.setTimeOut("3");
+
+    assertThrows(
+        HopException.class, () -> 
SshData.openConnection(SshTestSupport.localVariables(), meta));
+  }
+
+  @Test
+  void executeCommandAgainstEmbeddedServerReturnsOutput() throws Exception {
+    SshMeta meta = SshTestSupport.passwordMeta(sshPort);
+    String token = "hop-data-test";
+    Session session = SshData.openConnection(SshTestSupport.localVariables(), 
meta);
+    try {
+      SessionResult result =
+          SessionResult.executeCommand(session, 
SshTestSupport.echoCommand(token));
+      assertTrue(result.getStd().contains(token), () -> "output was: " + 
result.getStd());
+    } finally {
+      session.disconnect();
+    }
+  }
+
+  @Test
+  void newDataInitialState() {
+    SshData data = new SshData();
+    assertEquals(-1, data.indexOfCommand);
+    assertFalse(data.wroteOneRow);
+  }
+
+  @AfterAll
+  static void stop() throws IOException {
+    if (server != null) {
+      stopSharedServer(server);
+    }
+  }
+}
diff --git 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshMetaTest.java
 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshMetaTest.java
index c300b672d3..2ea992e085 100644
--- 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshMetaTest.java
+++ 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshMetaTest.java
@@ -18,26 +18,40 @@
 package org.apache.hop.pipeline.transforms.ssh;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import org.apache.hop.core.Const;
+import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.encryption.Encr;
 import org.apache.hop.core.encryption.TwoWayPasswordEncoderPluginType;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
 import org.apache.hop.core.util.EnvUtil;
+import org.apache.hop.core.variables.Variables;
 import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.pipeline.transforms.loadsave.LoadSaveTester;
+import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.io.TempDir;
 import org.w3c.dom.Node;
 
-class SshMetaTest {
+class SshMetaTest extends SshTestSupport {
   @RegisterExtension
   static RestoreHopEngineEnvironmentExtension env = new 
RestoreHopEngineEnvironmentExtension();
 
@@ -83,7 +97,7 @@ class SshMetaTest {
   @Test
   void testRoundTrips() throws HopException {
     List<String> commonFields =
-        Arrays.<String>asList(
+        Arrays.asList(
             "dynamicCommandField",
             "command",
             "commandFieldName",
@@ -109,4 +123,199 @@ class SshMetaTest {
 
     tester.testSerialization();
   }
+
+  @Test
+  void defaultValues() {
+    SshMeta meta = new SshMeta();
+    assertEquals("22", meta.getPort());
+    assertEquals("stdOut", meta.getStdOutFieldName());
+    assertEquals("stdErr", meta.getStdErrFieldName());
+    assertTrue(meta.isUsePrivateKey());
+    assertFalse(meta.isDynamicCommandField());
+  }
+
+  @Test
+  void supportsErrorHandling() {
+    assertTrue(new SshMeta().supportsErrorHandling());
+  }
+
+  @Test
+  void getFieldsStaticCommandReplacesInputRowMeta() throws HopException {
+    SshMeta meta = new SshMeta();
+    meta.setDynamicCommandField(false);
+    meta.setStdOutFieldName("out");
+    meta.setStdErrFieldName("");
+
+    IRowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("inputField"));
+
+    meta.getFields(row, "ssh", null, null, new Variables(), null);
+
+    assertEquals(1, row.size());
+    assertEquals("out", row.getValueMeta(0).getName());
+  }
+
+  @Test
+  void getFieldsDynamicCommandKeepsInputFields() throws HopException {
+    SshMeta meta = new SshMeta();
+    meta.setDynamicCommandField(true);
+    meta.setStdOutFieldName("out");
+    meta.setStdErrFieldName("errFlag");
+
+    IRowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("cmd"));
+
+    meta.getFields(row, "ssh", null, null, new Variables(), null);
+
+    assertEquals(3, row.size());
+    assertEquals("cmd", row.getValueMeta(0).getName());
+    assertEquals("out", row.getValueMeta(1).getName());
+    assertEquals("errFlag", row.getValueMeta(2).getName());
+  }
+
+  @Test
+  void checkReportsErrorWhenServerMissing(@TempDir java.nio.file.Path tempDir) 
{
+    SshMeta meta = new SshMeta();
+    meta.setUsePrivateKey(false);
+    meta.setServerName(null);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setTransform(meta);
+    PipelineMeta pipelineMeta = new PipelineMeta();
+
+    meta.check(
+        remarks,
+        pipelineMeta,
+        transformMeta,
+        new RowMeta(),
+        new String[] {"input"},
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                r ->
+                    r.getType() == ICheckResult.TYPE_RESULT_ERROR
+                        && r.getText()
+                            .equals(
+                                BaseMessages.getString(
+                                    SshMeta.class, 
"SSHMeta.CheckResult.TargetHostMissing"))));
+  }
+
+  @Test
+  void checkReportsErrorWhenPrivateKeyEnabledButPathMissing() {
+    SshMeta meta = new SshMeta();
+    meta.setServerName("localhost");
+    meta.setUsePrivateKey(true);
+    meta.setKeyFileName(null);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setTransform(meta);
+
+    meta.check(
+        remarks,
+        new PipelineMeta(),
+        transformMeta,
+        new RowMeta(),
+        new String[] {"in"},
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                r ->
+                    r.getType() == ICheckResult.TYPE_RESULT_ERROR
+                        && r.getText()
+                            .equals(
+                                BaseMessages.getString(
+                                    SshMeta.class,
+                                    
"SSHMeta.CheckResult.PrivateKeyFileNameMissing"))));
+  }
+
+  @Test
+  void checkReportsErrorWhenNoInputTransforms() {
+    SshMeta meta = new SshMeta();
+    meta.setServerName("localhost");
+    meta.setUsePrivateKey(false);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setTransform(meta);
+
+    meta.check(
+        remarks,
+        new PipelineMeta(),
+        transformMeta,
+        new RowMeta(),
+        new String[0],
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                r ->
+                    r.getType() == ICheckResult.TYPE_RESULT_ERROR
+                        && r.getText()
+                            .equals(
+                                BaseMessages.getString(
+                                    SshMeta.class, 
"SSHMeta.CheckResult.NoInpuReceived"))));
+  }
+
+  @Test
+  void checkOkWhenServerAndInputPresent(@TempDir java.nio.file.Path keyDir) 
throws Exception {
+    Assumptions.assumeTrue(isSshKeygenAvailable(), "ssh-keygen is required to 
generate test keys");
+    String keyName = "ssh_meta";
+    Path privateKey = generateRsaKeyPair(keyDir, keyName);
+    SshMeta meta = new SshMeta();
+    meta.setServerName("localhost");
+    meta.setUsePrivateKey(true);
+    meta.setKeyFileName(privateKey.toUri().toString());
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    TransformMeta transformMeta = new TransformMeta();
+    transformMeta.setTransform(meta);
+
+    meta.check(
+        remarks,
+        new PipelineMeta(),
+        transformMeta,
+        new RowMeta(),
+        new String[] {"in"},
+        new String[0],
+        null,
+        new Variables(),
+        null);
+
+    assertTrue(
+        remarks.stream()
+            .anyMatch(
+                r ->
+                    r.getType() == ICheckResult.TYPE_RESULT_OK
+                        && r.getText()
+                            .equals(
+                                BaseMessages.getString(
+                                    SshMeta.class, 
"SSHMeta.CheckResult.TargetHostOK"))));
+    assertFalse(
+        remarks.stream()
+            .anyMatch(
+                r ->
+                    r.getType() == ICheckResult.TYPE_RESULT_OK
+                        && r.getText()
+                            .equals(
+                                BaseMessages.getString(
+                                    SshMeta.class,
+                                    "SSHMeta.CheckResult.PrivateKeyFileExists",
+                                    privateKey.toString()))));
+  }
 }
diff --git 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTestSupport.java
 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTestSupport.java
new file mode 100644
index 0000000000..224c6cd2a6
--- /dev/null
+++ 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTestSupport.java
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.transforms.ssh;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.PublicKey;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.channel.ChannelSession;
+import org.apache.sshd.server.command.Command;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+
+/** Shared fixtures for SSH transform unit tests. */
+@Slf4j
+class SshTestSupport {
+  static final String SSH_USER = "root";
+  static final String SSH_PASS = "pwd123456";
+
+  /** Passphrase used when generating test keys; must match {@link 
#privateKeyMeta}. */
+  static final String TEST_KEY_PASSPHRASE = "hello";
+
+  static SshServer startPasswordSshServer(PublicKey authorizedPublicKey) 
throws IOException {
+    SshServer sshd = SshServer.setUpDefaultServer();
+    sshd.setPort(0);
+    sshd.setKeyPairProvider(new SimpleGeneratorHostKeyProvider());
+    sshd.setPasswordAuthenticator(
+        (username, password, session) -> SSH_USER.equals(username) && 
SSH_PASS.equals(password));
+    sshd.setPublickeyAuthenticator(
+        (username, key, session) ->
+            SSH_USER.equals(username)
+                && authorizedPublicKey != null
+                && KeyUtils.compareKeys(authorizedPublicKey, key));
+    sshd.setCommandFactory((channel, command) -> new TestEchoCommand(command));
+    sshd.start();
+    return sshd;
+  }
+
+  static void stopSharedServer(SshServer server) throws IOException {
+    if (server != null && server.isOpen()) {
+      server.stop();
+    }
+  }
+
+  static SshMeta passwordMeta(int port) {
+    SshMeta meta = new SshMeta();
+    meta.setUsePrivateKey(false);
+    meta.setServerName("localhost");
+    meta.setPort(String.valueOf(port));
+    meta.setUserName(SSH_USER);
+    meta.setPassword(SSH_PASS);
+    meta.setCommand("echo hop-ssh-test");
+    meta.setStdOutFieldName("stdOut");
+    meta.setStdErrFieldName("stdErr");
+    return meta;
+  }
+
+  static SshMeta privateKeyMeta(int port, String privateKeyPath) {
+    return privateKeyMeta(port, privateKeyPath, TEST_KEY_PASSPHRASE);
+  }
+
+  static SshMeta privateKeyMeta(int port, String privateKeyPath, String 
keyPassphrase) {
+    SshMeta meta = passwordMeta(port);
+    meta.setUsePrivateKey(true);
+    meta.setPassword(null);
+    meta.setKeyFileName(privateKeyPath);
+    meta.setPassPhrase(keyPassphrase);
+    return meta;
+  }
+
+  static PublicKey loadPublicKeyFromPubFile(Path pubFile) throws Exception {
+    AuthorizedKeyEntry entry =
+        AuthorizedKeyEntry.parseAuthorizedKeyEntry(
+            Files.readString(pubFile, StandardCharsets.UTF_8));
+    return entry.resolvePublicKey(null, null);
+  }
+
+  static boolean isSshKeygenAvailable() {
+    try {
+      boolean windows = System.getProperty("os.name", 
"").toLowerCase().contains("win");
+      ProcessBuilder builder = new ProcessBuilder(windows ? "where" : "which", 
"ssh-keygen");
+      Process process = builder.redirectErrorStream(true).start();
+      return process.waitFor() == 0;
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
+  static Path generateRsaKeyPair(Path directory, String keyName)
+      throws IOException, InterruptedException {
+    Files.createDirectories(directory);
+    Path privateKey = directory.resolve(keyName);
+    Process process =
+        new ProcessBuilder(
+                "ssh-keygen",
+                "-t",
+                "rsa",
+                "-b",
+                "2048",
+                "-m",
+                "PEM",
+                "-N",
+                "",
+                "-f",
+                privateKey.toAbsolutePath().toString(),
+                "-q")
+            .redirectErrorStream(true)
+            .start();
+    int exit = process.waitFor();
+    if (exit != 0 || !Files.exists(privateKey)) {
+      throw new IOException("ssh-keygen failed with exit code " + exit);
+    }
+    return privateKey;
+  }
+
+  static IVariables localVariables() {
+    return new Variables();
+  }
+
+  /** Command understood by the embedded test SSH server. */
+  static String echoCommand(String token) {
+    return "echo " + token;
+  }
+
+  /** Minimal exec handler so tests do not depend on OS shell behavior. */
+  private static final class TestEchoCommand implements Command {
+    private final String command;
+    private OutputStream stdout;
+    private OutputStream stderr;
+    private ExitCallback exitCallback;
+
+    private TestEchoCommand(String command) {
+      this.command = command;
+    }
+
+    @Override
+    public void setInputStream(InputStream in) {
+      // no stdin required
+    }
+
+    @Override
+    public void setOutputStream(OutputStream out) {
+      this.stdout = out;
+    }
+
+    @Override
+    public void setErrorStream(OutputStream err) {
+      this.stderr = err;
+    }
+
+    @Override
+    public void setExitCallback(ExitCallback callback) {
+      this.exitCallback = callback;
+    }
+
+    @Override
+    public void start(ChannelSession channel, Environment env) throws 
IOException {
+      try {
+        if (command != null && command.startsWith("echo ")) {
+          String payload = command.substring(5) + "\n";
+          stdout.write(payload.getBytes(StandardCharsets.UTF_8));
+          stdout.flush();
+          exitCallback.onExit(0);
+        } else if (command != null && command.startsWith("stderr ")) {
+          String payload = command.substring(7) + "\n";
+          stderr.write(payload.getBytes(StandardCharsets.UTF_8));
+          stderr.flush();
+          exitCallback.onExit(0);
+        } else {
+          stderr.write(("unsupported command: " + command + 
"\n").getBytes(StandardCharsets.UTF_8));
+          stderr.flush();
+          exitCallback.onExit(1);
+        }
+      } finally {
+        closeQuietly(stdout);
+        closeQuietly(stderr);
+      }
+    }
+
+    private static void closeQuietly(OutputStream stream) throws IOException {
+      if (stream instanceof Closeable closeable) {
+        closeable.close();
+      }
+    }
+
+    @Override
+    public void destroy(ChannelSession channel) {
+      // nothing to clean up
+    }
+  }
+}
diff --git 
a/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTransformTest.java
 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTransformTest.java
new file mode 100644
index 0000000000..ba0effa13e
--- /dev/null
+++ 
b/plugins/transforms/ssh/src/test/java/org/apache/hop/pipeline/transforms/ssh/SshTransformTest.java
@@ -0,0 +1,248 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.pipeline.transforms.ssh;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.security.PublicKey;
+import org.apache.hop.core.QueueRowSet;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaBoolean;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.transform.TransformErrorMeta;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.apache.sshd.server.SshServer;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Unit test ssh transform */
+@ExtendWith(RestoreHopEngineEnvironmentExtension.class)
+class SshTransformTest extends SshTestSupport {
+  @TempDir static java.nio.file.Path tempDir;
+  private static SshServer server;
+  private static int sshPort;
+
+  private TransformMockHelper<SshMeta, SshData> mockHelper;
+
+  @BeforeAll
+  static void beforeAll() throws Exception {
+    PublicKey authorizedPublicKey = null;
+    if (isSshKeygenAvailable()) {
+      String keyName = "ssh_form";
+      Path privateKeyPath = generateRsaKeyPair(tempDir, keyName);
+      authorizedPublicKey = loadPublicKeyFromPubFile(Path.of(privateKeyPath + 
".pub"));
+    }
+    server = startPasswordSshServer(authorizedPublicKey);
+    sshPort = server.getPort();
+  }
+
+  @BeforeEach
+  void setUp() {
+    mockHelper = new TransformMockHelper<>("SSH TEST", SshMeta.class, 
SshData.class);
+    when(mockHelper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(mockHelper.iLogChannel);
+    when(mockHelper.pipeline.isRunning()).thenReturn(true);
+  }
+
+  @AfterEach
+  void tearDown() {
+    mockHelper.cleanUp();
+  }
+
+  @Test
+  void initFailsWhenUserNameMissing() {
+    SshMeta meta = passwordMeta(sshPort);
+    meta.setUserName(null);
+    Ssh ssh = newSsh(meta, new SshData());
+
+    assertFalse(ssh.init());
+  }
+
+  @Test
+  void initFailsWhenStdOutFieldMissing() {
+    SshMeta meta = passwordMeta(sshPort);
+    meta.setStdOutFieldName(null);
+    Ssh ssh = newSsh(meta, new SshData());
+
+    assertFalse(ssh.init());
+  }
+
+  @Test
+  void initOpensSessionOnValidPasswordConfig() {
+    SshMeta meta = passwordMeta(sshPort);
+    SshData data = new SshData();
+    Ssh ssh = newSsh(meta, data);
+
+    assertTrue(ssh.init());
+    assertNotNull(data.session);
+    assertTrue(data.session.isConnected());
+    ssh.dispose();
+    assertNull(data.session);
+  }
+
+  @Test
+  void staticCommandProcessesSingleRow() throws Exception {
+    String token = "hop-static";
+    SshMeta meta = passwordMeta(sshPort);
+    meta.setCommand(echoCommand(token));
+    SshData data = new SshData();
+    Ssh ssh = newSsh(meta, data);
+    QueueRowSet output = attachOutput(ssh);
+
+    assertTrue(ssh.init());
+    assertTrue(ssh.processRow());
+    Object[] row = output.getRow();
+    assertNotNull(row);
+    assertTrue(row[0].toString().contains(token));
+
+    assertFalse(ssh.processRow());
+    ssh.dispose();
+  }
+
+  @Test
+  void dynamicCommandReadsCommandFromInputField() throws Exception {
+    String token = "hop-dynamic";
+    SshMeta meta = passwordMeta(sshPort);
+    meta.setDynamicCommandField(true);
+    meta.setCommandFieldName("cmd");
+    meta.setCommand(null);
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("cmd"));
+    QueueRowSet input = new QueueRowSet();
+    input.setRowMeta(inputMeta);
+    input.putRow(inputMeta, new Object[] {echoCommand(token)});
+    input.setDone();
+
+    SshData data = new SshData();
+    Ssh ssh = newSsh(meta, data);
+    ssh.addRowSetToInputRowSets(input);
+    QueueRowSet output = attachOutput(ssh);
+
+    assertTrue(ssh.init());
+    assertTrue(ssh.processRow());
+    Object[] row = output.getRow();
+    assertNotNull(row);
+    assertEquals("cmd", inputMeta.getValueMeta(0).getName());
+    assertTrue(row[1].toString().contains(token), () -> "row=" + 
java.util.Arrays.toString(row));
+
+    assertFalse(ssh.processRow());
+    ssh.dispose();
+  }
+
+  @Test
+  void dynamicCommandEmptyCommandRoutesToErrorRowWhenErrorHandlingEnabled() 
throws Exception {
+    SshMeta meta = passwordMeta(sshPort);
+    meta.setDynamicCommandField(true);
+    meta.setCommandFieldName("cmd");
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("cmd"));
+    QueueRowSet input = new QueueRowSet();
+    input.setRowMeta(inputMeta);
+    input.putRow(inputMeta, new Object[] {""});
+    input.setDone();
+
+    when(mockHelper.transformMeta.isDoingErrorHandling()).thenReturn(true);
+    TransformErrorMeta errorMeta = mock(TransformErrorMeta.class);
+    
when(mockHelper.transformMeta.getTransformErrorMeta()).thenReturn(errorMeta);
+    when(errorMeta.getErrorRowMeta(any())).thenReturn(inputMeta);
+
+    SshData data = new SshData();
+    Ssh ssh = newSsh(meta, data);
+    ssh.addRowSetToInputRowSets(input);
+    attachOutput(ssh);
+
+    assertTrue(ssh.init());
+    assertTrue(ssh.processRow());
+    ssh.dispose();
+  }
+
+  @Test
+  void outputIncludesStdErrFlagWhenConfigured() throws Exception {
+    SshMeta meta = passwordMeta(sshPort);
+    meta.setCommand(echoCommand("flag-test"));
+    meta.setStdErrFieldName("hasErr");
+
+    SshData data = new SshData();
+    Ssh ssh = newSsh(meta, data);
+    QueueRowSet output = attachOutput(ssh);
+
+    assertTrue(ssh.init());
+    assertTrue(ssh.processRow());
+    Object[] row = output.getRow();
+    assertNotNull(row);
+    assertEquals(2, row.length);
+    assertInstanceOf(Boolean.class, row[1]);
+
+    ssh.dispose();
+  }
+
+  @Test
+  void getFieldsForStaticCommandClearsInputAndAddsOutput() throws Exception {
+    SshMeta meta = passwordMeta(sshPort);
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("keep"));
+
+    meta.getFields(row, "ssh", null, null, localVariables(), null);
+
+    assertEquals(2, row.size());
+    assertEquals("stdOut", row.getValueMeta(0).getName());
+    assertEquals(ValueMetaBoolean.TYPE_BOOLEAN, row.getValueMeta(1).getType());
+  }
+
+  @AfterAll
+  static void stop() throws IOException {
+    if (server != null) {
+      stopSharedServer(server);
+    }
+  }
+
+  private Ssh newSsh(SshMeta meta, SshData data) {
+    when(mockHelper.transformMeta.getTransform()).thenReturn(meta);
+    Ssh ssh =
+        new Ssh(
+            mockHelper.transformMeta, meta, data, 0, mockHelper.pipelineMeta, 
mockHelper.pipeline);
+    ssh.setMetadataProvider(mock(IHopMetadataProvider.class));
+    return ssh;
+  }
+
+  private static QueueRowSet attachOutput(Ssh ssh) {
+    QueueRowSet output = new QueueRowSet();
+    ssh.addRowSetToOutputRowSets(output);
+    return output;
+  }
+}
diff --git a/pom.xml b/pom.xml
index f0b624d9ef..cdf677e4c4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -163,6 +163,7 @@
         <set-highest-basedir-phase>initialize</set-highest-basedir-phase>
         
<site-repo-url>scpexe://people.apache.org/www/hop.apache.org/maven/</site-repo-url>
         <sonar-maven-plugin.version>5.5.0.6356</sonar-maven-plugin.version>
+        <sonar.exclusions>integration-tests/ssh/keys/**</sonar.exclusions>
         <spotless.skip>false</spotless.skip>
         <swtbot.version>4.3.0</swtbot.version>
         <target.jdk.version>21</target.jdk.version>

Reply via email to