From 45e63bb164987265e529bdc77647fd07a44b3657 Mon Sep 17 00:00:00 2001 From: petrsnd Date: Thu, 18 Jun 2026 15:40:38 -0600 Subject: [PATCH 1/2] add openwrt ssh sample, working / validated, still missing discover authorized keys --- .github/ISSUE_TEMPLATE/question.md | 22 + docs/agent-reference/samples-index.md | 1 + samples/ssh/README.md | 2 + samples/ssh/openwrt/OpenWrtSsh.json | 1061 +++++++++++++++++++++++++ samples/ssh/openwrt/README.md | 84 ++ 5 files changed, 1170 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/question.md create mode 100644 samples/ssh/openwrt/OpenWrtSsh.json create mode 100644 samples/ssh/openwrt/README.md diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..78c9060 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,22 @@ +--- +name: Question +about: Ask a question about using the samples, templates, agent skills, or documentation +title: '[Question] ' +labels: question +assignees: '' +--- + +## Question +What would you like to know? + +## Context +What are you trying to accomplish? Include the protocol (SSH/HTTP), target system, and operations involved if relevant. + +## What You've Tried +What have you already looked at or attempted? Link any docs, samples, or templates you've consulted. + +## SPP Version +Which version of Safeguard for Privileged Passwords are you using? (if relevant) + +## Additional Context +Sanitized script excerpts, task log output, or anything else that helps answer the question. Remove any credentials or other sensitive data. diff --git a/docs/agent-reference/samples-index.md b/docs/agent-reference/samples-index.md index 2f1786d..7d0faed 100644 --- a/docs/agent-reference/samples-index.md +++ b/docs/agent-reference/samples-index.md @@ -37,6 +37,7 @@ CI runs the same script with `-CheckOnly` and fails the build if the committed c | ssh | Interactive | CheckSystem, CheckPassword, ChangePassword, DiscoverSshHostKey | — | [`samples/ssh/linux-app-text-config/LinuxApplicationTextConfig.json`](../../samples/ssh/linux-app-text-config/LinuxApplicationTextConfig.json) | [README](../../samples/ssh/linux-app-text-config/README.md) | | ssh | Batch | DiscoverSshHostKey, CheckSystem, CheckPassword, ChangePassword | — | [`samples/ssh/linux-ssh-batch-mode/LinuxSshBatchModeExample.json`](../../samples/ssh/linux-ssh-batch-mode/LinuxSshBatchModeExample.json) | [README](../../samples/ssh/linux-ssh-batch-mode/README.md) | | ssh | Interactive | CheckSystem, CheckPassword, ChangePassword, DiscoverSshHostKey, DiscoverAccounts | — | [`samples/ssh/openbsd/OpenBSD.json`](../../samples/ssh/openbsd/OpenBSD.json) | [README](../../samples/ssh/openbsd/README.md) | +| ssh | Interactive | CheckSystem, CheckPassword, ChangePassword, DiscoverSshHostKey, ChangeSshKey, CheckSshKey, DiscoverAuthorizedKeys | — | [`samples/ssh/openwrt/OpenWrtSsh.json`](../../samples/ssh/openwrt/OpenWrtSsh.json) | [README](../../samples/ssh/openwrt/README.md) | | ssh | Batch | DiscoverSshHostKey, CheckSystem, CheckPassword, ChangePassword | — | [`samples/ssh/restricted-authorized-key/RestrictedAuthorizedKeyExample.json`](../../samples/ssh/restricted-authorized-key/RestrictedAuthorizedKeyExample.json) | [README](../../samples/ssh/restricted-authorized-key/README.md) | | ssh | Interactive | CheckSystem, CheckPassword, ChangePassword, DiscoverSshHostKey, DiscoverAccounts | — | [`samples/ssh/vcenter-appliance/vCenterServerAppliance.json`](../../samples/ssh/vcenter-appliance/vCenterServerAppliance.json) | [README](../../samples/ssh/vcenter-appliance/README.md) | diff --git a/samples/ssh/README.md b/samples/ssh/README.md index 5a07c5a..a633544 100644 --- a/samples/ssh/README.md +++ b/samples/ssh/README.md @@ -12,6 +12,7 @@ Tested custom platform scripts for managing systems over SSH. These samples cove | [generic-linux-ssh-keys](generic-linux-ssh-keys/) | ⭐⭐⭐ | Linux with SSH authorized key lifecycle | | [linux-app-text-config](linux-app-text-config/) | ⭐⭐⭐ | Application passwords in text config files | | [linux-ssh-batch-mode](linux-ssh-batch-mode/) | ⭐⭐ | Linux using batch-mode SSH (no interactive shell) | +| [openwrt](openwrt/) | ⭐⭐⭐ | OpenWrt (dropbear/BusyBox) password + SSH key lifecycle | | [restricted-authorized-key](restricted-authorized-key/) | ⭐⭐⭐ | Linux with restricted key + passwordless sudo | | [vcenter-appliance](vcenter-appliance/) | ⭐⭐⭐ | VMware vCenter Server Appliance | @@ -20,6 +21,7 @@ Tested custom platform scripts for managing systems over SSH. These samples cove - **New to SSH platforms?** Start with [generic-linux](generic-linux/) — it's the baseline. - **Need account discovery?** Look at [generic-linux-with-discovery](generic-linux-with-discovery/). - **Managing SSH keys?** See [generic-linux-ssh-keys](generic-linux-ssh-keys/). +- **Managing an OpenWrt router?** See [openwrt](openwrt/) — dropbear/BusyBox quirks handled. - **Prefer batch commands over interactive shells?** Try [linux-ssh-batch-mode](linux-ssh-batch-mode/). ## Related Docs diff --git a/samples/ssh/openwrt/OpenWrtSsh.json b/samples/ssh/openwrt/OpenWrtSsh.json new file mode 100644 index 0000000..345168b --- /dev/null +++ b/samples/ssh/openwrt/OpenWrtSsh.json @@ -0,0 +1,1061 @@ +{ + "Id": "OpenWrtSshScript", + "BackEnd": "Scriptable", + "CheckSystem": { + "Parameters": [ + { "Port": { "Type": "Integer", "Required": false, "DefaultValue": 22 } }, + { "Timeout": { "Type": "Integer", "Required": false, "DefaultValue": 20 } }, + { "Address": { "Type": "String", "Required": true } }, + { "FuncUserName": { "Type": "String", "Required": true } }, + { "FuncPassword": { "Type": "Secret", "Required": false } }, + { "AssetName": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "DelegationPrefix": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "CheckHostKey": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "HostKey": { "Type": "String", "Required": false } }, + { "RequestTerminal": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "UserKey": { "Type": "Secret", "Required": false } } + ], + "Do": [ + { "Function": { "Name": "LoginSsh", "ResultVariable": "LoginResult", "Parameters": [ "%FuncUserName%", "%FuncPassword%", "%UserKey::$%" ] } }, + { "Function": { "Name": "SetUpEnvironment", "ResultVariable": "ValidateResult" } }, + { "Function": { "Name": "ValidateAccount", "ResultVariable": "ValidateResult" } }, + { "Function": { "Name": "LogoutSsh", "ResultVariable": "LogoutResult" } }, + { "Return": { "Value": "%ValidateResult%" } } + ] + }, + "CheckPassword": { + "Parameters": [ + { "Port": { "Type": "Integer", "Required": false, "DefaultValue": 22} }, + { "Timeout": { "Type": "Integer", "Required": false, "DefaultValue": 20} }, + { "Address": { "Type": "String", "Required": true } }, + { "FuncUserName": { "Type": "String", "Required": true } }, + { "FuncPassword": { "Type": "Secret", "Required": false } }, + { "AccountUserName": { "Type": "String", "Required": true } }, + { "AccountPassword": { "Type": "Secret", "Required": true } }, + { "AssetName": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "DelegationPrefix": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "CheckHostKey": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "HostKey": { "Type": "String", "Required": false } }, + { "RequestTerminal": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "UserKey": { "Type": "Secret", "Required": false } } + ], + "Do": [ + { "Function": { "Name": "LoginSsh", "ResultVariable": "LoginResult", "Parameters": ["%FuncUserName%", "%FuncPassword%", "%UserKey::$%"] } }, + { "Function": { "Name": "SetUpEnvironment", "ResultVariable": "ValidateResult" } }, + { "Function": { "Name": "ValidatePassword", "ResultVariable": "ValidationResult" } }, + { "Function": { "Name": "LogoutSsh", "ResultVariable": "LogoutResult" } }, + { "Return": { "Value": "%ValidationResult%" } } + ] + }, + "ChangePassword": { + "Parameters": [ + { "Port": { "Type": "Integer", "Required": false, "DefaultValue": 22} }, + { "Timeout": { "Type": "Integer", "Required": false, "DefaultValue": 20} }, + { "Address": { "Type": "String", "Required": true } }, + { "FuncUserName": { "Type": "String", "Required": true } }, + { "FuncPassword": { "Type": "Secret", "Required": false } }, + { "AccountUserName": { "Type": "String", "Required": true } }, + { "AccountPassword": { "Type": "Secret", "Required": true } }, + { "NewPassword": { "Type": "Secret", "Required": true } }, + { "AssetName": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "DelegationPrefix": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "CheckHostKey": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "HostKey": { "Type": "String", "Required": false } }, + { "RequestTerminal": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "UserKey": { "Type": "Secret", "Required": false } } + ], + "Do": [ + { "Function": { "Name": "LoginSsh", "ResultVariable": "LoginResult", "Parameters": [ "%FuncUserName%", "%FuncPassword%", "%UserKey::$%" ] } }, + { "Function": { "Name": "ChangeUserPassword", "ResultVariable": "ChangeUserPasswordResult" } }, + { "Function": { "Name": "LogoutSsh", "ResultVariable": "LogoutResult" } }, + { "Return": { "Value": "%ChangeUserPasswordResult%" } } + ] + }, + "DiscoverSshHostKey": { + "Parameters": [ + { "Port": { "Type": "Integer", "Required": false, "DefaultValue": 22} }, + { "Timeout": { "Type": "Integer", "Required": false, "DefaultValue": 20} }, + { "Address": { "Type": "String", "Required": true } }, + { "AssetName": { "Type": "String", "Required": false, "DefaultValue": "" } } + ], + "Do": [ + { "Function": { "Name": "DiscoverHostKeyForAsset" } }, + { "Return": { "Value": true } } + ] + }, + "ChangeSshKey": { + "Parameters": [ + { "Port": { "Type": "Integer", "Required": false, "DefaultValue": 22} }, + { "Timeout": { "Type": "Integer", "Required": false, "DefaultValue": 20} }, + { "Address": { "Type": "String", "Required": true } }, + { "FuncUserName": { "Type": "String", "Required": true } }, + { "FuncPassword": { "Type": "Secret", "Required": false } }, + { "AccountUserName": { "Type": "String", "Required": true } }, + { "AssetName": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "DelegationPrefix": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "CheckHostKey": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "HostKey": { "Type": "String", "Required": false } }, + { "UserKey": { "Type": "Secret", "Required": false } }, + { "OldSshKey": { "Type": "String", "Required": false } }, + { "NewSshKey": { "Type": "String", "Required": false } }, + { "NewSshPrivateKey": { "Type": "Secret", "Required": false, "DefaultValue" : "" } }, + { "RequestTerminal": { "Type": "Boolean", "Required": false, "DefaultValue": true } } + ], + "Do": [ + { "Comment" : { "Text" : "Configure an SSH key and/or remove an SSH key from the user's authorized keystore" }}, + { "Function": { "Name": "LoginSsh", "ResultVariable": "LoginResult", "Parameters": ["%FuncUserName%", "%FuncPassword%", "%UserKey::$%"] } }, + { "Function": { "Name": "SetUpEnvironment", "ResultVariable": "ValidateResult" } }, + { "Function": { "Name": "DiscoverSshKeyConfiguration", "ResultVariable": "SshConfigData" } }, + { "Function" : { "Name" : "ConfigureNewKey", "Parameters" : [ "%{ SshConfigData }%" ] }}, + { "Function" : { "Name" : "RemoveOldKey", "Parameters" : [ "%{ SshConfigData }%" ] }}, + { "Function": { "Name": "LogoutSsh", "ResultVariable": "LogoutResult" } }, + { "Return": { "Value": true } } + ] + }, + "CheckSshKey": { + "Parameters": [ + { "Port": { "Type": "Integer", "Required": false, "DefaultValue": 22} }, + { "Timeout": { "Type": "Integer", "Required": false, "DefaultValue": 20} }, + { "Address": { "Type": "String", "Required": true } }, + { "FuncUserName": { "Type": "String", "Required": true } }, + { "FuncPassword": { "Type": "Secret", "Required": false } }, + { "AccountUserName": { "Type": "String", "Required": true } }, + { "AssetName": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "DelegationPrefix": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "CheckHostKey": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "HostKey": { "Type": "String", "Required": false } }, + { "UserKey": { "Type": "Secret", "Required": false } }, + { "OldSshKey": { "Type": "String", "Required": true } }, + { "RequestTerminal": { "Type": "Boolean", "Required": false, "DefaultValue": true } } + ], + "Do": [ + { "Comment" : { "Text" : "Check if an SSH key is configured in a user's authorized keystore" }}, + { "Function": { "Name": "LoginSsh", "ResultVariable": "LoginResult", "Parameters": ["%FuncUserName%", "%FuncPassword%", "%UserKey::$%"] } }, + { "Function": { "Name": "SetUpEnvironment", "ResultVariable": "ValidateResult" } }, + { "Function": { "Name": "DiscoverSshKeyConfiguration", "ResultVariable": "SshConfigData" } }, + { "Function": { "Name": "IsOldKeyConfigured", "Parameters" : [ "%{ SshConfigData }%" ] , "ResultVariable" : "ValidateResult" }}, + { "Function": { "Name": "LogoutSsh", "ResultVariable": "LogoutResult" } }, + { "Return": { "Value": "%ValidateResult%" } } + ] + }, + "DiscoverAuthorizedKeys": { + "Parameters": [ + { "Port": { "Type": "Integer", "Required": false, "DefaultValue": 22} }, + { "Timeout": { "Type": "Integer", "Required": false, "DefaultValue": 20} }, + { "Address": { "Type": "String", "Required": true } }, + { "FuncUserName": { "Type": "String", "Required": true } }, + { "FuncPassword": { "Type": "Secret", "Required": false } }, + { "AccountUserName": { "Type": "String", "Required": true } }, + { "AssetName": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "DelegationPrefix": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "CheckHostKey": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "HostKey": { "Type": "String", "Required": false } }, + { "UserKey": { "Type": "Secret", "Required": false } }, + { "RequestTerminal": { "Type": "Boolean", "Required": false, "DefaultValue": true } } + ], + "Do": [ + { "Comment" : { "Text" : "Discover all SSH keys configured in a user's authorized keystore" }}, + { "Function": { "Name": "LoginSsh", "ResultVariable": "LoginResult", "Parameters": ["%FuncUserName%", "%FuncPassword%", "%UserKey::$%"] } }, + { "Function": { "Name": "SetUpEnvironment", "ResultVariable": "ValidateResult" } }, + { "Function": { "Name": "DiscoverSshKeyConfiguration", "ResultVariable": "SshConfigData" } }, + { "Function": { "Name": "ReportSshKeys", "Parameters" : [ "%{ SshConfigData }%" ] }}, + { "Function": { "Name": "LogoutSsh", "ResultVariable": "LogoutResult" } }, + { "Return": { "Value": true } } + ] + }, + "LoginSsh": { + "Parameters": [ + { "UserName": { "Type": "String", "Required": true } }, + { "Password": { "Type": "Secret", "Required": false } }, + { "LoginKey": { "Type": "Secret", "Required": false } } + ], + "Do": [ + { "Status": { "Type": "Connecting", "Percent": 30, + "Message": { "Name": "AssetConnectingWithAddress", "Parameters": [ "%AssetName%", "%Address%" ] } } + }, + { "Try": { + "Do": [ + { + "Connect": { + "ConnectionObjectName": "Global:ConnectSsh", + "Type": "Ssh", + "Port": "%Port%", + "NetworkAddress": "%Address%", + "Login": "%UserName%", + "Password": "%Password::$%", + "RequestTerminal": "%RequestTerminal%", + "UserKey": "%LoginKey::$%", + "CheckHostKey": "%CheckHostKey%", + "Hostkey": "%HostKey::$%", + "Timeout": "%Timeout%" + } + } + ], + "Catch": [ + { "Throw": { "Value": "SSH Connection Error: %Exception%" } } + ] + } + }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "LoginCheckBuffer" } }, + { "Condition": { + "If": "Regex.IsMatch(LoginCheckBuffer, @\"(You are required to change your password)|(Your password has expired)|(Check for other error messages here)\")", + "Then": { + "Do": [ + { "Function": { "Name": "LogoutSsh" } }, + { "Return": { "Value": false } } + ] + } + } + }, + { "Return": { "Value": true } } + ] + }, + "LogoutSsh": { + "Do": [ + { "Disconnect": { "ConnectionObjectName": "ConnectSsh" } }, + { "Return": { "Value": true } } + ] + }, + "SetUpEnvironment": { + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "unset TERM; stty -echo; LANG=C; LC_ALL=C; SUDO_PROMPT='SUDO password for %p:'; export LANG LC_ALL SUDO_PROMPT; echo \"INIT_CHECK=$?\"" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "FlushBuffer", "ExpectRegex" : "INIT_CHECK=0", "ExpectTimeout": 2000 } }, + { "Return": { "Value": true } } + ] + }, + "ValidateAccount": { + "Do": [ + { "Status": { "Type": "Checking", "Percent": "60", + "Message": { "Name": "AssetTestingConnectionWithAddress", "Parameters": [ "%AssetName%", "%Address%" ] } } + }, + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%DelegationPrefix::$% grep -E -q '^(%FuncUserName%):' /etc/shadow; echo \"CHECKSYS=$?\"" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "ContainsSecret": true, "BufferName": "ReturnStatus" } }, + { "Condition": { + "If": "Regex.IsMatch(ReturnStatus, @\"SUDO password for\")", + "Then": { + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%FuncPassword%", "ContainsSecret": true } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "ContainsSecret": true, "BufferName": "ReturnStatus" } } + ] + } + } + }, + { "Switch": { + "MatchValue": "%ReturnStatus%", + "Cases": [ + { + "CaseValue": "CHECKSYS=0", + "Do": [ + { "Return": { "Value": true } } + ] + }, + { + "CaseValue": "(incorrect password attempts)|(Sorry, try again)|(Check for other error messages here)", + "Do": [ + { "Status": { "Type": "Checking", "Percent": 80, + "Message": { "Name": "InsufficientDelegationPrivileges", "Parameters": [ "%DelegationPrefix::$%" ] } } + } + ] + }, + { + "CaseValue": "(CHECKSYS=[1-9]+.*)|(Permission denied)", + "Do": [ + { "Status": { "Type": "Checking", "Percent": 80, + "Message": { "Name": "InsufficientPrivilegesAccessPassword" } } + } + ] + } + ] + } + }, + { "Return": { "Value": "false" } } + ] + }, + "ValidatePassword": { + "Do": [ + { "Status": { "Type": "Checking", "Percent": 50, + "Message": { "Name": "LookingUpUser", "Parameters": [ "%AccountUserName%" ] } } + }, + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "grep -q '^%AccountUserName%:' /etc/passwd; echo \"CHECKUSER=$?\"" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "ReturnStatus" } }, + { "Switch": { + "MatchValue": "%ReturnStatus%", + "Cases": [ + { "CaseValue": "CHECKUSER=[1-9]+.*", + "Do": [ + { "Status": { "Type": "Checking", "Percent": 80, + "Message": { "Name": "AccountNotFound", "Parameters": [ "%AccountUserName%" ] } } + }, + { "Return": { "Value": false } } + ] + }, + { "CaseValue": "CHECKUSER=0", + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%DelegationPrefix::$% grep '^%AccountUserName%:' /etc/shadow" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "ContainsSecret": true, "BufferName": "AccountEntry" } }, + { "Condition": { + "If": "Regex.IsMatch(AccountEntry, @\"SUDO password for\")", + "Then": { + "Do": [ + { + "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%FuncPassword%", "ContainsSecret": true } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "ContainsSecret": true, "BufferName": "AccountEntry" } } + ] + } + } + }, + { "Switch": { + "MatchValue": "%AccountEntry%", + "Cases": [ + { "CaseValue": "%AccountUserName%:[^:]*.*:[^:].*:", + "Do": [ + { + "Status": { "Type": "Checking", "Percent": 75, + "Message": { "Name": "VerifyingPassword" } } + }, + { "CompareShadowHash": { "Password": "%AccountPassword%", "SaltedHash": "%AccountEntry%", "ResultVariable": "PasswordHashMatched" } }, + { "Condition": { + "If": "PasswordHashMatched == true", + "Then": { + "Do": [ + { "Return": { "Value": true } } + ] + } + } + }, + { "Return": { "Value": false } } + ] + }, + { "CaseValue": "(incorrect password attempts)|(Sorry, try again)|(Check for other error messages here)", + "Do": [ + { "Status": { "Type": "Checking", "Percent": 80, + "Message": { "Name": "InsufficientDelegationPrivileges", "Parameters": [ "%DelegationPrefix::$%" ] } } + }, + { "Return": { "Value": false } } + ] + }, + { "CaseValue": "Permission denied", + "Do": [ + { "Status": { "Type": "Checking", "Percent": 80, + "Message": { "Name": "InsufficientPrivilegesAccessPassword" } } + }, + { "Return": { "Value": false } } + ] + } + ], + "DefaultCase": { + "Do": [ + { "Return": { "Value": false } } + ] + } + } + } + ] + } + ] + } + }, + { "Return": { "Value": false } } + ] + }, + "ChangeUserPassword": { + "Do": [ + { "Status": { "Type": "Changing", "Percent": 50, + "Message": { "Name": "ChangingPassword", "Parameters": [ "%AccountUserName%" ] } } + }, + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "grep -q '^%AccountUserName%:' /etc/passwd; echo \"CHECKUSER=$?\"" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "ReturnStatus" } }, + { "Switch": { + "MatchValue": "%ReturnStatus%", + "Cases": [ + { "CaseValue": "CHECKUSER=[1-9]+.*", + "Do": [ + { "Status": { "Type": "Checking", "Percent": 80, + "Message": { "Name": "AccountNotFound", "Parameters": [ "%AccountUserName%" ] } } + }, + { "Return": { "Value": false } } + ] + }, + { "CaseValue": "CHECKUSER=0", + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%DelegationPrefix::$% passwd %AccountUserName%; echo \"CHGPASS=$?\"" } }, + { "Receive": {"ConnectionObjectName": "ConnectSsh", "BufferName": "PasswdAttempt", "ExpectRegex": "([Nn]ew.*[Pp]assword:)|(CHGPASS=)" } }, + { "Comment": { "Text": "BusyBox passwd run as root prompts 'New password:' then 'Retype password:'; there is no sudo or current-password prompt" }}, + { "Condition": { + "If": "Regex.IsMatch(PasswdAttempt, @\".*[Nn]ew.*[Pp]assword:\")", + "Then": { + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%NewPassword%", "ContainsSecret": true } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "PasswdAttempt", "ExpectRegex": "([Rr]etype.*[Pp]assword:)|([Nn]ew.*[Pp]assword:)|(CHGPASS=)" } } + ] + } + } + }, + { "Condition": { + "If": "Regex.IsMatch(PasswdAttempt, @\"([Rr]etype.*[Pp]assword:)|([Nn]ew.*[Pp]assword:)\")", + "Then": { + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%NewPassword%", "ContainsSecret": true } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "PasswdAttempt", "ExpectRegex": "CHGPASS=" } } + ] + } + } + }, + { "Switch": { + "MatchValue": "%PasswdAttempt%", + "Cases": [ + { "CaseValue": "CHGPASS=0", + "Do": [ + { "Return": { "Value": true } } + ] + }, + { "CaseValue": "(CHGPASS=[1-9]+.*)|(BAD PASSWORD)|(Have exhausted maximum number of retries)", + "Do": [ + { + "Status": { "Type": "Changing", "Percent": 80, + "Message": { "Name": "PasswordChangeFailed", "Parameters": [ "%AccountUserName%" ] } } + } + ] + }, + { + "CaseValue": "(incorrect password attempts)|(Sorry, try again)|(Check for other error messages here)", + "Do": [ + { "Status": { "Type": "Changing", "Percent": 80, + "Message": { "Name": "InsufficientDelegationPrivileges", "Parameters": [ "%DelegationPrefix::$%" ] } } + } + ] + }, + { "CaseValue": "(Only root can specify a user name)|(Permission denied)|(may not view or modify password information)", + "Do": [ + { + "Status": { "Type": "Changing", "Percent": 80, + "Message": { "Name": "InsufficientPrivilegesAccessPassword" } } + } + ] + }, + { "CaseValue": "(BAD PASSWORD)|(Have exhausted maximum number of retries)", + "Do": [ + { "Status": { "Type": "Changing", "Percent": 80, + "Message": { "Name": "PasswordTooWeak", "Parameters": [ "%AccountUserName%" ] } } + } + ] + } + ] + } + } + ] + } + ] + } + }, + { "Return": { "Value": false } } + ] + }, + "DiscoverHostKeyForAsset": { + "Do": [ + { "Status": { "Type": "Connecting", "Percent": 30, + "Message": { "Name": "AssetConnectingWithAddress", "Parameters": [ "%AssetName%", "%Address%" ] }} + }, + { + "Try": { + "Do": [ + { + "DiscoverSshHostKey": { + "HostKeyVariableName": "HostKey", + "SoftwareVersionVariableName": "GLOBAL:ServerSoftwareName", + "Port": "%Port%", + "NetworkAddress": "%Address%", + "Timeout": "%Timeout%", + "DiscoverHostKey": true, + "Type": "Ssh" + } + }, + { "WriteResponseObject": { "Value": "%HostKey::$%" } } + ], + "Catch": [ + { + "Condition": { + "If": "string.IsNullOrEmpty(Exception)", + "Then": { + "Do": [ + { "Status": { "Type": "Connecting", "Percent": 95, + "Message": { "Name": "AssetConnectFailedWithAddress","Parameters": [ "%AssetName%", "%Address%" ] } } + }, + { "Throw": { "Value": "SSH Connection Error" } } + ] + }, + "Else": { + "Do": [ + { "Status": { "Type": "Connecting", "Percent": 95, + "Message": { "Name": "AssetConnectFailedWithReasonAndAddress", "Parameters": [ "%AssetName%", "%Address%", "%Exception%" ] } } + }, + { "Throw": { "Value": "%Exception%" } } + ] + } + } + } + ] + } + } + ] + }, + "DiscoverSshKeyConfiguration": { + "Do": [ + { "Comment" : { "Text" : "Discover the SSH authorized key path templates configured on this asset, and get the user information required to evaluate the templates" }}, + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "echo uid=`id -u %AccountUserName%`", "(?m)(?<=\\buid=)\\d+", false ], + "ResultVariable" : "AccountUid" + } }, + + { "Condition": { + "If": "string.IsNullOrEmpty(AccountUid)", + "Then" : { "Do" : [ + { "Status": { "Type": "Checking", "Percent": 80, "Message": { "Name": "AccountNotFound", "Parameters": [ "%AccountUserName%" ] } }}, + { "Return" : { "Value" : { "Result" : false } } } + ]} + }}, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "echo gid=`id -g %AccountUserName%`", "(?m)(?<=\\bgid=)\\d+", false ], + "ResultVariable" : "AccountGid" + } }, + + { "Comment" : { "Text" : "OpenWrt/dropbear has no sshd binary to query; the root authorized_keys file is fixed at /etc/dropbear/authorized_keys" }}, + { "Eval" : { "Expression" : "KeyTemplateLine = \"/etc/dropbear/authorized_keys\"" }}, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "echo \"home=\"`grep '^%AccountUserName%:' /etc/passwd | cut -d ':' -f 6`", "(?m)(?<=\\bhome=)\\S+", true ], + "ResultVariable" : "AccountHomeDir" + }}, + + { "Split" : { "Source" : "%KeyTemplateLine::$%", "DelimiterChars" : [ " " ], "ResultVariableName" : "KeystoreTemplateList", "RemoveEmptyEntries" : true }}, + + { "SetItem" : { "Name" : "SshConfigData", "Value" : { + "SshdConfigFile" : "/etc/config/dropbear", + "KeystoreTemplateList" : "%{ KeystoreTemplateList }%", + "KeystorePathList" : "%{ new List() }%", + "AccountHomeDir" : "%{ AccountHomeDir }%", + "AccountUid" : "%{ AccountUid }%", + "AccountGid" : "%{ AccountGid }%", + "Result" : true + } } }, + { "Function" : { "Name" : "ResolveKeyTemplates", "Parameters" : [ "%{ SshConfigData }%" ] }}, + { "Return" : { "Value" : "%{ SshConfigData }%" }} + + ] + }, + + "ParseKey" : { + "Parameters" : [ + { "KeyString": { "Type": "String"} } + ], + "Do" : [ + { "Comment" : { "Text" : "Parse a line from an authorized key file, checking for a valid SSH key" }}, + { "SetItem" : { "Name" : "Comment", "Value" : ""}}, + { "SetItem" : { "Name" : "KeyMatch", "Value" : "%{ Regex.Match(KeyString, \"(?m)^(.*\\s)?(ssh-rsa|ssh-dss|ssh-ed25519|ecdsa-sha2-nistp521|ecdsa-sha2-nistp384|ecdsa-sha2-nistp256|sk-ecdsa-sha2-nistp256@openssh\\.com|sk-ssh-ed25519@openssh\\.com)\\s+(\\S+)\\s*(.*)\") }%" }}, + + { "Condition" : { + "If" : "!KeyMatch.Success", + "Then" : { "Do" : [ + { "Log" : { "Text" : "Ignoring invalid key : %{ KeyString }%" }}, + { "Return" : { "Value" : { "Result" : false } }} + ]}}}, + + { "SetItem" : { "Name" : "KeyValue", "Value" : "%{ KeyMatch.Groups[3].Value.ToString() }%"}}, + + { "Function": { "Name": "ValidateBase64String", + "Parameters": ["%{ KeyValue }%"], + "ResultVariable": "IsValidKey" + } }, + { "Condition" : { + "If" : "!IsValidKey", + "Then" : { "Do": [ + { "Log" : { "Text" : "Ignoring invalid key : %{ KeyString }%" }}, + { "Return" : { "Value" : { "Result" : false } }} + ]}}}, + + + { "SetItem" : { "Name" : "KeyType", "Value" : "%{ KeyMatch.Groups[2].Value.ToString() }%"}}, + { "SetItem" : { "Name" : "SshKey", "Value" : "%{ $\"{KeyType} {KeyValue}\" }%"}}, + { "Condition" : { + "If" : "KeyMatch.Groups.Count > 3", + "Then" : { "Do" : [ + { "Eval" : { "Expression" : "Comment=KeyMatch.Groups[4].Value" }} + ]}}} , + + { "Return" : { "Value" : { + "Result" : true, "Key" : "%{ SshKey }%", "KeyType" : "%{ KeyType }%", "KeyValue" : "%{ KeyValue }%", "KeyComment" : "%{ Comment }%" + } } } + ]}, + + "GetKeyFileContents" : { + "Parameters" : [ + { "Filename" : { "Type" : "String" } } + ], + "Do": [ + + { "Comment" : { "Text" : "Get the contents of an authorized keys file (if it exists), as an array of lines" }}, + { "Status": { "Type": "Checking", "Percent": 70, "Message": { "Name": "DiscoverKeyFile", "Parameters": ["%Filename%", "%AccountUserName%", "%AssetName%" ] } } }, + + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "res=`%DelegationPrefix::$% cat %{ Filename }% 2>&1`; test $? -eq 0 && echo \"Content=${res},ALLDONE=$?\" || echo \"FOUND=FAIL $res\"", "(?s)(?<=\\b(Content=)|(FOUND=FAIL)).*", true ], + "ResultVariable" : "FileContents" + } }, + + { "Condition" : { + "If" : "Regex.IsMatch(FileContents, \"FOUND=FAIL\")", + "Then": { "Do" : [ + { "Return" : { "Value" : { "Result" : false }}} + ]}}}, + + + { "Eval" : { "Expression" : "FileContents = Regex.Replace(FileContents, \"(.*Content=)|(,ALLDONE=\\d+)\", \"\", RegexOptions.SingleLine)" } }, + { "Split" : { "Source" : "%FileContents::$%", "DelimiterChars" : [ "\n" ], "ResultVariableName" : "LineList", "RemoveEmptyEntries" : true }}, + { "Return" : { "Value" : { + "Result" : true, "FileContents" : "%{ LineList }%" + }}} + ]}, + + + "ReportKeysFromFile" : { + "Parameters" : [ + { "Filename" : { "Type" : "String" }}, + { "LineList" : { "Type" : "Array" }} + ], + "Do" : [ + { "Comment" : { "Text" : "Report the list of authorized keys in a file" }}, + { "SetItem" : { "Name" : "KeyCount", "Value" : 0}}, + + { "ForEach" : { "CollectionName" : "LineList", "ElementName" : "Key", + "Body" : { "Do" : [ + { "Function" : { "Name" : "ParseKey", + "Parameters" : [ "%{ Key }%" ], + "ResultVariable" : "ParsedKey" + }}, + + { "Condition" : { + "If" : "ParsedKey.Result", + "Then" : { "Do" : [ + { "Eval" : { "Expression" : "KeyCount = KeyCount + 1" } }, + { "WriteDiscoveredSshKey" : { "KeyType" : "%{ ParsedKey.KeyType }%", "SshKey" : "%{ ParsedKey.KeyValue }%", "Comment" : "%{ ParsedKey.KeyComment }%" } } + ]}}} + ]}}}, + { "Status": { "Type": "Checking", "Percent": 70, "Message": { "Name": "DiscoveredAuthKeys", "Parameters": [ "%{ KeyCount }%", "%{ Filename }%" ] } } }, + { "Return" : { "Value" : "%{ KeyCount }%" }} + ] + }, + + + "ResolveKeyTemplates" : { + "Parameters" : [ + { "SshConfigData" : { "Type" : "Object" } } + ], + "Do" : [ + { "Comment" : { "Text" : "Evaluate the authorized key path templates to get the user's list of authorized key files" }}, + { "SetItem" : { "Name" : "KeystoreTemplateList", "Value" : "%{ SshConfigData.KeystoreTemplateList }%" }}, + { "SetItem" : { "Name" : "KeystorePathList", "Value" : "%{ new List() }%" }}, + { "SetItem": { "Name": "TemplateListString", "Value": "%{ String.Join(\",\", KeystoreTemplateList) }%" } }, + { "Status": { "Type": "Checking", "Message": { "Name": "DiscoveringAuthKeyFileTemplates", + "Parameters": [ "%{ TemplateListString }%", "%AssetName%" ] }, "Percent": 60 } }, + { "ForEach" : { "CollectionName" : "KeystoreTemplateList", "ElementName" : "Template", + "Body" : { "Do" : [ + + { "SetItem" : { "Name" : "KeyPath", "Value" : "%{ Regex.Replace(Template, \"%h\", SshConfigData.AccountHomeDir )}%" } }, + { "Eval" : { "Expression" : "KeyPath = Regex.Replace(KeyPath, \"%u\", AccountUserName)" } }, + { "Eval" : { "Expression" : "KeyPath = Regex.Replace(KeyPath, \"%%\", \"%\")" } }, + + { "Condition" : { "If" : "!KeyPath.StartsWith(\"/\")", + "Then" : { "Do" : [ { "Eval" : { "Expression" : "KeyPath = $\"{SshConfigData.AccountHomeDir}/{KeyPath}\"" }} ] } }}, + + { "Eval" : { "Expression" : "KeystorePathList.Add(KeyPath)" }} + ]}}}, + { "SetItem": { "Name": "KeyListString", "Value": "%{ String.Join(\",\", KeystorePathList) }%" } }, + { "Status": { "Type": "Checking", "Message": { "Name": "DiscoveringAuthKeyFiles", "Parameters": ["%{ KeyListString }%"] }, "Percent": "65" } }, + { "Eval" : { "Expression" : "SshConfigData.KeystorePathList = KeystorePathList" }} + ] + }, + + + "GetValueFromOutput" : { + "Parameters" : [ + { "Cmd": { "Type": "String", "Required": true } }, + { "Expect": { "Type": "String", "Required": true } }, + { "ThrowOnFail": { "Type": "Boolean", "Required": true } } + ], + "Do" : [ + { "Comment" : { "Text" : "Run a command and extract a value from the expected output" }}, + { "SetItem": { "Name": "ErrorRegex", "Value" : "(Permission denied)|(SUDO password for)|(incorrect password attempts)|(Sorry, try again)|(ejected)|(may not run sudo)|(is not allowed to run sudo)|(is not in the sudoers file)|(is not allowed to execute)|(Request rejected)" } }, + { "Comment" : { "Text" : "Run a command and parse the expected output" }}, + + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%{ Cmd }%"} }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "buff", "ExpectTimeout" : 1000, "ExpectRegex" : "(%Expect%)|(%ErrorRegex%)"}}, + { "SetItem": { "Name": "Result", "Value" : false }}, + + + { "SetItem": { "Name": "Finished", "Value" : false }}, + { "For" : { + "Before" : "Finished = false", + "Condition" : "Finished == false", + "Body" : [ + { "Condition": { + "If": "Regex.IsMatch(buff, \"SUDO password for\")", + "Then" : { "Do" : [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%{ FuncPassword }%", "ContainsSecret" : true} }, + { "Condition": { + "If": "Regex.IsMatch(buff, \"SUDO password for.*SUDO password for\")", + "Then" : { "Do" : [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%{ FuncPassword }%", "ContainsSecret" : true} } + ]}}}, + + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "buff", "ExpectTimeout" : 2000, "ExpectRegex": "(%Expect%)|(%ErrorRegex%)" } } + ]}, + "Else" : { "Do" : [ + { "Eval": { "Expression": "Finished = true" }} ] } + }} + ]} }, + + { "Condition": { + "If": "Regex.IsMatch(buff, ErrorRegex, RegexOptions.SingleLine)", + "Then" : { "Do" : [ + { "Status": { "Type": "Checking", "Percent": 80, + "Message": { "Name": "InsufficientDelegationPrivileges", "Parameters": [ "%DelegationPrefix::$%" ] } } + }, + { "Condition": { + "If": "ThrowOnFail", + "Then" : { "Do" : [ + { "Throw" : { "Value" : "Command Failed" }} + ] } + }} + ] }}}, + + { "SetItem": { "Name": "CheckLine", "Value": "%{ Regex.Match(buff, Expect, RegexOptions.IgnoreCase) }%" } }, + + { "Condition": { "If": "!CheckLine.Success", + "Then" : { "Do" : [ + { "Log" : { "Text" : "Did not find expected output in: %{ buff }%" }}, + { "Condition": { + "If": "ThrowOnFail", + "Then" : { "Do" : [ + { "Throw" : { "Value" : "Command Failed" }} + ] }}} + ]}, + "Else" : { "Do" : [ + { "SetItem" : { "Name" : "Output", "Value" : "%{ CheckLine.Value }%" }}, + { "Eval" : { "Expression" : "Output=Output.Trim()" }}, + { "Return" : { "Value" : "%{ Output }%" }} ]} + }} + ] + }, + "ReportSshKeys" : { + "Parameters" : [ + { "SshConfigData" : { "Type" : "Object" } } + ], + "Do" : [ + { "Comment" : { "Text" : "Report all valid SSH keys from each of the user's authorized key files" }}, + { "SetItem" : { "Name" : "KeystorePaths", "Value" : "%{ SshConfigData.KeystorePathList }%" }}, + { "SetItem" : { "Name" : "TotalKeys", "Value" : 0 }}, + { "ForEach" : { "CollectionName" : "KeystorePaths", "ElementName" : "Path", + "Body" : { "Do" : [ + + { "Function" : { "Name" : "GetKeyFileContents", + "Parameters" : [ "%{ Path }%" ], + "ResultVariable" : "Result" + }}, + { "Condition" : { + "If" : "Result.Result", + "Then" : { "Do" : [ + { "Function" : { "Name" : "ReportKeysFromFile", + "Parameters" : [ "%{ Path }%", "%{ Result.FileContents }%" ], + "ResultVariable" : "NumKeys" + }}, + { "Eval" : { "Expression" : "TotalKeys = TotalKeys + NumKeys" }} + ] }}} + + ]}}} , + { "Status" : { "Type" : "Checking", "Message" : { "Name" : "TotalDiscoveredAuthKeys", "Parameters" : [ "%{ TotalKeys }%", "%{ AccountUserName }%" ] }, "Percent" : "80" } } + ] + }, + + "ValidateBase64String" : { + "Parameters": [ + { "EncodedString": { "Type": "string" } } + ], + "Do": [ + { "Comment": { "Text": "Validate an SSH key: check that it is a valid base64-encoded key" } }, + { "Try": { "Do": [ + { "Eval": { "Expression": "%{ Convert.FromBase64String(EncodedString) }%" } } + ], + "Catch": [ + { "Comment": { "Text": "An invalid expression exception here means the key string cannot be decoded from base64. It wraps the System.FormatException can be found in the inner exception" } }, + { "Log": { "Text": "Key string is not a valid base64-encoded key: %EncodedString::$%" } }, + { "Return": { "Value": false } } + ] + } + }, + { "Return": { "Value" : true }} + ] + }, + + "IsSshKeyConfiguredInFile" : { + "Parameters" : [ + { "Key" : { "Type" : "String" }}, + { "Path" : { "Type" : "String" }}, + { "LineList" : { "Type" : "Array" }} + ], + "Do" : [ + { "Comment" : { "Text" : "Check if an SSH key is configured in an authorized key file" }}, + { "ForEach" : { "CollectionName" : "LineList", "ElementName" : "OneKey", + "Body" : { "Do" : [ + { "Function" : { "Name" : "ParseKey", "Parameters" : [ "%{ OneKey }%" ], "ResultVariable" : "ParsedKey" }}, + + { "Condition" : { + "If" : "ParsedKey.Result", + "Then" : { "Do" : [ + { "SetItem" : { "Name" : "CheckKey", "Value" : "%{ ParsedKey.Key}%" }}, + { "Condition" : { + "If" : "CheckKey.Equals(Key)", + "Then" : { "Do" : [ + { "Return" : { "Value" : true }} + ]} + }} + + ]}}} + ]}}}, + { "Return" : { "Value" : false }} + ] + }, + + + "FindKeyInAnyFile" : { + "Parameters" : [ + { "Key" : { "Type" : "String" }}, + { "SshConfigData" : { "Type" : "Object" }} + ], + "Do" : [ + { "Comment" : { "Text" : "Search for an SSH key in any of a user's authorized key files" }}, + { "SetItem" : { "Name" : "KeystorePaths", "Value" : "%{ SshConfigData.KeystorePathList }%" }}, + + + { "ForEach" : { "CollectionName" : "KeystorePaths", "ElementName" : "Path", + "Body" : { "Do" : [ + + { "Function" : { "Name" : "GetKeyFileContents", "Parameters" : [ "%{ Path }%" ], "ResultVariable" : "Result" }}, + { "Condition" : { + "If" : "Result.Result", + "Then" : { "Do" : [ + { "Function" : { "Name" : "IsSshKeyConfiguredInFile", + "Parameters" : [ "%Key%", "%{ Path }%", "%{ Result.FileContents }%" ], + "ResultVariable" : "KeyFound" + }}, + { "Condition" : { + "If" : "KeyFound", + "Then" : { "Do" : [ + { "Return" : { "Value" : { "Configured" : true, "Path" : "%{ Path }%" }}} + ]} + }} + ]}}} + ]}}}, + { "Return" : { "Value" : { "Configured" : false, "Path" : "" }}} + ] + }, + + + "ConfigureNewKey" : { + "Parameters" : [ + { "SshConfigData" : { "Type" : "Object" }} + ], + "Do" : [ + { "Comment" : { "Text" : "Configure a new SSH key in the user's authorized keystore, if not already configured" }}, + + { "Condition" : { + "If" : "string.IsNullOrEmpty(NewSshKey)", + "Then" : { "Do" : [ + { "Status": { "Type": "Changing", "Message": { "Name": "NoKeyToAdd" }, "Percent": "30" } }, + { "Return": { }} + ]}}}, + + { "Status": { "Type": "Changing", "Message": { "Name": "InstallingSshKey" }, "Percent": "30" } }, + { "Function" : { "Name" : "FindKeyInAnyFile", + "Parameters" : [ "%NewSshKey%", "%{ SshConfigData }%" ], + "ResultVariable" : "KeyFound" + }}, + + { "Condition" : { + "If" : "KeyFound.Configured", + "Then" : { "Do" : [ + + { "Status": { "Type": "Changing", "Message": { "Name": "SshKeyConfiguredInFile", "Parameters": ["%{ KeyFound.Path }%"] }, "Percent": "60" } }, + { "Return": { }} + ] }}}, + + { "SetItem" : { "Name" : "KeyPath", "Value" : "%{ SshConfigData.KeystorePathList[0] }%" }}, + { "SetItem" : { "Name" : "BackupKeyPath", "Value" : "'%{ KeyPath }%_backup'" }}, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% rm -f %{ BackupKeyPath }%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Try": { "Do": [ + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "echo \"PATH=\"`dirname %{ KeyPath }%`", "(?m)(?<=\\bPATH=)\\S+", true ], + "ResultVariable" : "AuthKeyDir" + } }, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% mkdir -p '%{ AuthKeyDir }%' && %DelegationPrefix::$% chown %{ AccountUserName }% '%{ AuthKeyDir }%' && %DelegationPrefix::$% chmod u=rwx,go-rwx '%{ AuthKeyDir }%' ; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : + { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% touch %{ KeyPath }%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : + { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% cp -pf %{ KeyPath }% %{ BackupKeyPath }%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "echo '%NewSshKey% %NewSshKeyComment::$%' | %DelegationPrefix::$% tee -a %{ KeyPath }%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% chown %{ SshConfigData.AccountUid }%:%{ SshConfigData.AccountGid }% %KeyPath%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% chmod u=rw,go-rwx %KeyPath%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : { "Name" : "TestNewAuthorizedKey", + "Parameters" : [ "%AccountUserName%", "%NewSshPrivateKey::$%" ] + } } + + ], + "Catch": [ + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% test -f %BackupKeyPath% && %DelegationPrefix::$% mv %{ BackupKeyPath }% %{ KeyPath }% ; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + { "Throw" : { "Value" : "Failed to configured new SSH key" }} + ] }}, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% rm -f %{ BackupKeyPath }%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } } + ] + }, + + "RemoveOldKey" : { + "Parameters" : [ + { "SshConfigData" : { "Type" : "Object" }} + ], + "Do" : [ + { "Comment" : { "Text" : "Remove an SSH key from the user's authorized keystore, if configured" }}, + { "Status": { "Type": "Changing", "Message": { "Name": "RemovingOldSshKey" }, "Percent": "80" } }, + { "Condition" : { + "If" : "string.IsNullOrEmpty(OldSshKey)", + "Then" : { "Do" : [ + { "Status": { "Type": "Changing", "Message": { "Name": "NoKeyToRemove" }, "Percent": "80" } }, + { "Return" : { }} + ] } } }, + + { "Function" : { "Name" : "FindKeyInAnyFile", + "Parameters" : [ "%OldSshKey%", "%{ SshConfigData }%" ], + "ResultVariable" : "KeyFound" + }}, + { "Condition" : { + "If" : "!KeyFound.Configured", + "Then" : { "Do" : [ + { "Log": { "Value": "Key is not configured" }}, + { "Return": { }} + ] }}}, + + { "SetItem" : { "Name" : "KeyPath", "Value" : "'%{ KeyFound.Path }%'" }}, + { "SetItem" : { "Name" : "NewKeyPath", "Value" : "'%{ KeyFound.Path }%_Updated'" }}, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% cat %{ KeyPath }% | grep -v '%{ OldSshKey }%' | %DelegationPrefix::$% tee %{ NewKeyPath }%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% mv %{ NewKeyPath }% %{ KeyPath }%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% chown %{ SshConfigData.AccountUid }%:%{ SshConfigData.AccountGid }% %KeyPath%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } }, + + { "Function" : { "Name" : "GetValueFromOutput", + "Parameters" : [ "%DelegationPrefix::$% chmod u=rw,go-rwx %KeyPath%; echo RESULT=$?", "(?s)(RESULT=0)", true ], + "ResultVariable" : "Output" + } } + ] + }, + "IsOldKeyConfigured" : { + "Parameters" : [ + { "SshConfigData" : { "Type" : "Object" }} + ], + "Do" : [ + + { "Comment" : { "Text" : "Check if the old SSH key is configured in the user's authorized keystore" }}, + { "Status": { "Type": "Checking", "Message": { "Name": "CheckSshKey", "Parameters": [ "%AccountUserName%", "%AssetName%" ] }, "Percent": "30" } }, + + { "Condition" : { + "If" : "string.IsNullOrEmpty(OldSshKey)", + "Then" : { "Do" : [ + { "Return" : { "Value" : false }} + ]}}}, + + + { "Function" : { "Name" : "FindKeyInAnyFile", "Parameters" : [ "%OldSshKey%", "%{ SshConfigData }%" ], + "ResultVariable" : "KeyFound" + }}, + + { "Return" : { "Value" : "%{ KeyFound.Configured }%" }} + ] + }, + "TestNewAuthorizedKey" : { + "Parameters": [ + { "TestUser": { "Type": "string" } }, + { "TestKey": { "Type": "string" } } + ], + "Do": [ + { "Log": { "Text": "If the corresponding private key is supplied, test that the new authorized key can be used to login to the asset" } }, + { "Condition": { "If": "string.IsNullOrEmpty(NewSshPrivateKey)", "Then": { "Do": [ { "Return": { "Value": true } } ] } } }, + { "Try": { "Do": [ + { "Status": { "Type": "Changing", "Message": { "Name": "TestingKeyForServiceUser" }, "Percent": "80" } }, + { "Connect": { + "ConnectionObjectName": "TestConnectSsh", + "Type": "Ssh", + "Port": "%Port%", + "NetworkAddress": "%Address%", + "Login": "%TestUser%", + "UserKey": "%TestKey%", + "RequestTerminal": true, + "CheckHostKey": "%CheckHostKey%", + "Hostkey": "%HostKey::$%" } + }, + { "Disconnect": { "ConnectionObjectName": "TestConnectSsh" } }, + { "Return": { "Value": true } } + ], + "Catch": [ + { "Status": { "Type": "Connecting", "Message": { "Name": "AssetConnectFailedWithReason", "Parameters": ["%Address%", "%Exception::$%"] }, "Percent": "80" } }, + { "Status": { "Type": "Changing", "Message": { "Name": "NewSshKeyTestFailed" }, "Percent": "95" } }, + { "Throw": { "Value": "Key is not valid" } } + ] + } } + ]} +} diff --git a/samples/ssh/openwrt/README.md b/samples/ssh/openwrt/README.md new file mode 100644 index 0000000..3b00b65 --- /dev/null +++ b/samples/ssh/openwrt/README.md @@ -0,0 +1,84 @@ +[← SSH Samples](../README.md) + +# OpenWrt (dropbear / BusyBox) + +This sample manages local accounts on [OpenWrt](https://openwrt.org/) routers and embedded +devices over SSH. OpenWrt ships a [dropbear](https://matt.ucc.asn.au/dropbear/dropbear.html) +SSH server and a [BusyBox](https://busybox.net/) `ash` shell, so the login flow, `passwd` +prompts, and authorized-key store all differ from a standard OpenSSH/bash host. It supports +password check/change and the full SSH authorized-key lifecycle for the managed account. + +**Platform Script:** [`OpenWrtSsh.json`](./OpenWrtSsh.json) + +## Target System + +An OpenWrt host (tested against OpenWrt 25.12.x) reachable over SSH with `root` login enabled +(`config dropbear` option `RootPasswordAuth 'on'`). The managed account is typically `root`, +which on OpenWrt is the single privileged local account. + +## Operations Implemented + +| Operation | Description | +| --- | --- | +| `CheckSystem` | Verifies the service account can log in and read `/etc/shadow`. | +| `CheckPassword` | Validates a managed-account password against its `/etc/shadow` hash. | +| `ChangePassword` | Changes the managed-account password with BusyBox interactive `passwd`. | +| `CheckSshKey` | Reports whether `OldSshKey` is installed in the dropbear authorized-keys store. | +| `ChangeSshKey` | Installs `NewSshKey`, optionally tests `NewSshPrivateKey`, then removes `OldSshKey`. | +| `DiscoverAuthorizedKeys` | Reads the dropbear authorized-keys file and emits discovered SSH keys. | +| `DiscoverSshHostKey` | Retrieves the SSH host key for the target asset. | + +## OpenWrt / BusyBox differences from the generic Linux samples + +This script is derived from [`generic-linux-ssh-keys`](../generic-linux-ssh-keys/) with the +following adaptations: + +- **No `sudo`.** OpenWrt has no `sudo` by default and the managed account is `root`, so the + `DelegationPrefix` parameter defaults to empty and every reference uses the null-safe form + `%DelegationPrefix::$%` (a bare `%DelegationPrefix%` throws `VariableIsNullException` when + the value is empty/null). +- **BusyBox `passwd` prompts.** Running `passwd` as `root` prompts `New password:` then + `Retype password:` (not `Retype new password:`) and never asks for the current password. + The `ChangeUserPassword` send/receive sequence matches these prompts. +- **dropbear authorized-keys path.** Keys live in `/etc/dropbear/authorized_keys` (file mode + `600`, directory mode `700`), not `~/.ssh/authorized_keys`. `DiscoverSshKeyConfiguration` + hardcodes this path instead of probing `sshd -T -C`, which dropbear does not provide. +- **`grep -E`** is used in place of GNU `egrep`, and `stty` is not present on BusyBox (the + shell-init `stty -echo` is best-effort and its absence is harmless). + +## Prerequisites + +- An OpenWrt host reachable over SSH with root password authentication enabled +- A service account (`root`) that can read `/etc/shadow` and write `/etc/dropbear/authorized_keys` +- For key operations: managed accounts whose keys are stored in `/etc/dropbear/authorized_keys` + +## Deployment + +1. Upload the script: `Import-SafeguardCustomPlatformScript -FilePath ./OpenWrtSsh.json` +2. Create a custom platform using this script +3. Create an asset using the platform (accept the SSH host key) +4. Configure the service account and managed account (`root`) +5. Test with `Test-SafeguardAssetAccountPassword -ExtendedLogging` + +## Parameters + +- `OldSshKey` — Existing public key to verify or remove +- `NewSshKey` — New public key to install +- `NewSshPrivateKey` — Optional private key used to test the newly installed public key +- `DelegationPrefix` — Privilege-elevation command; defaults to empty on OpenWrt +- `UserKey` — Optional SSH private key for the service-account login + +## Limitations + +- Targets the dropbear authorized-keys store (`/etc/dropbear/authorized_keys`) only; does not + support OpenSSH `AuthorizedKeysFile` template resolution +- Does not implement a standalone `RemoveAuthorizedKey` operation +- Key parsing is limited to the key types handled in `ParseKey` +- Assumes the managed account can read `/etc/shadow` directly (root, no delegation) + +## Related + +- [SSH Key Management Guide](../../../docs/guides/ssh-key-management.md) +- [SSH Platforms Guide](../../../docs/guides/ssh-platforms.md) +- [generic-linux-ssh-keys](../generic-linux-ssh-keys/) — the OpenSSH/bash base this sample adapts +- [SPP Compatibility Matrix](../../../docs/reference/compatibility.md) From 4557e664bc17ad214b523014c600629c9f8bdc75 Mon Sep 17 00:00:00 2001 From: petrsnd Date: Thu, 18 Jun 2026 22:12:34 -0600 Subject: [PATCH 2/2] make OpenWrt SSH-key operations account-aware Branch the keystore path on account UID: root keeps /etc/dropbear/authorized_keys, non-root accounts use ~/.ssh/authorized_keys. Validated live for both. Update README. --- samples/ssh/openwrt/OpenWrtSsh.json | 8 +++++++- samples/ssh/openwrt/README.md | 26 ++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/samples/ssh/openwrt/OpenWrtSsh.json b/samples/ssh/openwrt/OpenWrtSsh.json index 345168b..3a372ba 100644 --- a/samples/ssh/openwrt/OpenWrtSsh.json +++ b/samples/ssh/openwrt/OpenWrtSsh.json @@ -520,8 +520,14 @@ "ResultVariable" : "AccountGid" } }, - { "Comment" : { "Text" : "OpenWrt/dropbear has no sshd binary to query; the root authorized_keys file is fixed at /etc/dropbear/authorized_keys" }}, + { "Comment" : { "Text" : "OpenWrt/dropbear has no sshd binary to query. root's keys live in the fixed /etc/dropbear/authorized_keys; non-root accounts use the standard ~/.ssh/authorized_keys (%h is expanded to the home dir by ResolveKeyTemplates)" }}, { "Eval" : { "Expression" : "KeyTemplateLine = \"/etc/dropbear/authorized_keys\"" }}, + { "Condition": { + "If": "AccountUid != \"0\"", + "Then" : { "Do" : [ + { "Eval" : { "Expression" : "KeyTemplateLine = \"%h/.ssh/authorized_keys\"" }} + ]} + }}, { "Function" : { "Name" : "GetValueFromOutput", "Parameters" : [ "echo \"home=\"`grep '^%AccountUserName%:' /etc/passwd | cut -d ':' -f 6`", "(?m)(?<=\\bhome=)\\S+", true ], diff --git a/samples/ssh/openwrt/README.md b/samples/ssh/openwrt/README.md index 3b00b65..c722963 100644 --- a/samples/ssh/openwrt/README.md +++ b/samples/ssh/openwrt/README.md @@ -14,7 +14,8 @@ password check/change and the full SSH authorized-key lifecycle for the managed An OpenWrt host (tested against OpenWrt 25.12.x) reachable over SSH with `root` login enabled (`config dropbear` option `RootPasswordAuth 'on'`). The managed account is typically `root`, -which on OpenWrt is the single privileged local account. +the single privileged local account, but non-root local accounts are also supported for SSH-key +operations (their keys resolve to `~/.ssh/authorized_keys`). ## Operations Implemented @@ -23,9 +24,9 @@ which on OpenWrt is the single privileged local account. | `CheckSystem` | Verifies the service account can log in and read `/etc/shadow`. | | `CheckPassword` | Validates a managed-account password against its `/etc/shadow` hash. | | `ChangePassword` | Changes the managed-account password with BusyBox interactive `passwd`. | -| `CheckSshKey` | Reports whether `OldSshKey` is installed in the dropbear authorized-keys store. | +| `CheckSshKey` | Reports whether `OldSshKey` is installed in the account's authorized-keys file. | | `ChangeSshKey` | Installs `NewSshKey`, optionally tests `NewSshPrivateKey`, then removes `OldSshKey`. | -| `DiscoverAuthorizedKeys` | Reads the dropbear authorized-keys file and emits discovered SSH keys. | +| `DiscoverAuthorizedKeys` | Reads the account's authorized-keys file and emits discovered SSH keys. | | `DiscoverSshHostKey` | Retrieves the SSH host key for the target asset. | ## OpenWrt / BusyBox differences from the generic Linux samples @@ -40,17 +41,21 @@ following adaptations: - **BusyBox `passwd` prompts.** Running `passwd` as `root` prompts `New password:` then `Retype password:` (not `Retype new password:`) and never asks for the current password. The `ChangeUserPassword` send/receive sequence matches these prompts. -- **dropbear authorized-keys path.** Keys live in `/etc/dropbear/authorized_keys` (file mode - `600`, directory mode `700`), not `~/.ssh/authorized_keys`. `DiscoverSshKeyConfiguration` - hardcodes this path instead of probing `sshd -T -C`, which dropbear does not provide. +- **dropbear authorized-keys path.** dropbear has no `sshd -T -C` to probe, so + `DiscoverSshKeyConfiguration` selects the path by account: `root` (uid 0) uses + `/etc/dropbear/authorized_keys` (file mode `600`, directory mode `700`); any other account + uses the standard `~/.ssh/authorized_keys`, resolved from `%h` against the account's home + directory just like the OpenSSH base sample. - **`grep -E`** is used in place of GNU `egrep`, and `stty` is not present on BusyBox (the shell-init `stty -echo` is best-effort and its absence is harmless). ## Prerequisites - An OpenWrt host reachable over SSH with root password authentication enabled -- A service account (`root`) that can read `/etc/shadow` and write `/etc/dropbear/authorized_keys` -- For key operations: managed accounts whose keys are stored in `/etc/dropbear/authorized_keys` +- A service account (`root`) that can read `/etc/shadow` and write each managed account's + authorized-keys file +- For key operations: `root` keys live in `/etc/dropbear/authorized_keys`; non-root accounts + use `~/.ssh/authorized_keys` ## Deployment @@ -70,8 +75,9 @@ following adaptations: ## Limitations -- Targets the dropbear authorized-keys store (`/etc/dropbear/authorized_keys`) only; does not - support OpenSSH `AuthorizedKeysFile` template resolution +- Resolves `root` keys to `/etc/dropbear/authorized_keys` and non-root keys to + `~/.ssh/authorized_keys`; does not parse OpenSSH `AuthorizedKeysFile` directives from a config + file (dropbear exposes none) - Does not implement a standalone `RemoveAuthorizedKey` operation - Key parsing is limited to the key types handled in `ParseKey` - Assumes the managed account can read `/etc/shadow` directly (root, no delegation)