This tutorial picks up where Your First SSH Script left off. You already have a working platform with CheckSystem, CheckPassword, and ChangePassword. Now you'll add account discovery, reusable functions, error handling, and structured logging to turn it into a production-quality platform.
- Completed Your First SSH Script — you should have a working script with
CheckSystem,CheckPassword, andChangePassword - A Linux target with SSH access and at least two local user accounts
- SPP with
safeguard-psinstalled
| Capability | What it does |
|---|---|
DiscoverAccounts |
Automatically finds local accounts on the system |
Reusable Functions |
Eliminates duplicated connection logic across operations |
Try/Catch error handling |
Makes operations resilient instead of crashing on unexpected output |
| Status messages | Reports progress back to SPP during long-running operations |
Account discovery lets SPP automatically find accounts on the managed system instead of requiring manual entry. The operation connects, queries the system for accounts, and reports each one back using WriteDiscoveredAccount.
Because discovery only reads data (no interactive prompts), it works well with ExecuteCommand in batch mode (RequestTerminal: false):
"DiscoverAccounts": {
"Parameters": [
{ "Address": { "Type": "String", "Required": true } },
{ "Port": { "Type": "Integer", "Required": false, "DefaultValue": 22 } },
{ "Timeout": { "Type": "Integer", "Required": false, "DefaultValue": 20 } },
{ "FuncUserName": { "Type": "String", "Required": true } },
{ "FuncPassword": { "Type": "Secret", "Required": false } },
{ "CheckHostKey": { "Type": "Boolean", "Required": false, "DefaultValue": true } },
{ "HostKey": { "Type": "String", "Required": false } }
],
"Do": [
{
"Connect": {
"ConnectionObjectName": "Global:SshConnection",
"Type": "Ssh",
"NetworkAddress": "%Address%",
"Port": "%Port%",
"Login": "%FuncUserName%",
"Password": "%FuncPassword::$%",
"RequestTerminal": false,
"CheckHostKey": "%CheckHostKey%",
"Hostkey": "%HostKey::$%",
"Timeout": "%Timeout%"
}
},
{
"SetItem": { "Name": "Stdout", "Value": "" }
},
{
"SetItem": { "Name": "Stderr", "Value": "" }
},
{
"SetItem": { "Name": "rc", "Value": 0 }
},
{
"ExecuteCommand": {
"ConnectionObjectName": "SshConnection",
"Command": "awk -F: '$3 >= 1000 && $7 !~ /nologin|false/ {print $1}' /etc/passwd",
"BufferName": "Stdout",
"StderrBufferName": "Stderr",
"ExitStatusBufferName": "rc"
}
},
{
"Condition": {
"If": "(rc != 0)",
"Then": { "Do": [
{ "Throw": { "Value": "Failed to query accounts: %Stderr%" } }
] }
}
},
{
"SetItem": { "Name": "AccountLines", "Value": "%{ Stdout.Split('\\n') }%" }
},
{
"ForEach": {
"CollectionName": "AccountLines",
"ElementName": "acct",
"Body": {
"Do": [
{
"Condition": {
"If": "acct != ''",
"Then": { "Do": [
{ "WriteDiscoveredAccount": { "Name": "%acct%" } }
] }
}
}
]
}
}
},
{
"Disconnect": { "ConnectionObjectName": "SshConnection" }
}
]
}Key points:
RequestTerminal: false— batch mode sends a command and captures stdout/stderr directly, without needingSend/Receiveprompt matching.SetItem— pre-declares variables (Stdout,Stderr,rc) beforeExecuteCommanduses them as output buffers. The validator requires all variables to be declared before use.ExecuteCommand— runs a single command;BufferName,StderrBufferName, andExitStatusBufferNamespecify where to store the output.ForEach— iterates over the split output, one account name per line.CollectionNamemust be a plain variable name, so the split is computed first withSetItem.WriteDiscoveredAccount— reports each account to SPP. This is how discovery populates the account list.- The
awkfilter keeps only real user accounts (UID ≥ 1000, active shell).
Test discovery from SPP's web UI under Asset Management > Discovery, or trigger it with:
Invoke-SafeguardAssetAccountDiscovery -Asset "TestHost"Your script now has four operations, and three of them (CheckSystem, CheckPassword, ChangePassword) all contain similar connection logic. This is a maintenance burden. If you need to change the connection pattern, you'd have to update it in three places.
Extract the common login logic into a function:
"LoginSsh": {
"Parameters": [
{ "UserName": { "Type": "String", "Required": true } },
{ "Password": { "Type": "Secret", "Required": false } }
],
"Do": [
{
"Connect": {
"ConnectionObjectName": "Global:SshConnection",
"Type": "Ssh",
"NetworkAddress": "%Address%",
"Port": "%Port%",
"Login": "%UserName%",
"Password": "%Password::$%",
"RequestTerminal": "%RequestTerminal%",
"CheckHostKey": "%CheckHostKey%",
"Hostkey": "%HostKey::$%",
"Timeout": "%Timeout%"
}
},
{ "Return": { "Value": true } }
]
}Then simplify each operation to call the function:
"CheckSystem": {
"Parameters": [ ... ],
"Do": [
{ "Function": { "Name": "LoginSsh", "Parameters": ["%FuncUserName%", "%FuncPassword%"] } },
{ "Disconnect": { "ConnectionObjectName": "SshConnection" } },
{ "Return": { "Value": true } }
]
}Functions are defined as top-level keys in the script (alongside operations like CheckSystem). SPP distinguishes them from operations because they aren't in the list of recognized operation names. Functions can access variables from the calling operation's Parameters that are marked Global: or passed explicitly.
Without error handling, any unexpected output (a different prompt format, a timeout, an unexpected error message) causes the entire operation to fail with a generic error. Wrapping critical sections in Try/Catch gives you control over failure behavior:
{
"Try": {
"Do": [
{ "Send": { "ConnectionObjectName": "SshConnection", "Buffer": "sudo passwd %AccountUserName%" } },
{ "Receive": { "ConnectionObjectName": "SshConnection", "BufferName": "PromptResult", "ExpectRegex": "([Nn]ew.*[Pp]assword:)|([Pp]assword:)" } },
{ "Send": { "ConnectionObjectName": "SshConnection", "Buffer": "%NewPassword%", "ContainsSecret": true } },
{ "Receive": { "ConnectionObjectName": "SshConnection", "BufferName": "PromptResult", "ExpectRegex": "([Rr]etype|[Rr]e-enter|[Cc]onfirm).*[Pp]assword:" } },
{ "Send": { "ConnectionObjectName": "SshConnection", "Buffer": "%NewPassword%", "ContainsSecret": true } },
{ "Receive": { "ConnectionObjectName": "SshConnection", "BufferName": "ChangeResult", "ExpectRegex": "(updated successfully)|(\\$\\s*$)|(#\\s*$)" } }
],
"Catch": {
"Do": [
{ "Log": { "Text": "ChangePassword failed: %{Exception.Message}%" } },
{ "Disconnect": { "ConnectionObjectName": "SshConnection" } },
{ "Return": { "Value": false } }
]
}
}
}The Catch block runs when any command in the Try block throws — whether from a Receive timeout, a connection drop, or an explicit Throw. This lets you:
- Log the failure reason for troubleshooting
- Disconnect cleanly instead of leaving orphaned sessions
- Return
falseto signal the operation failed without crashing the entire task
For operations that take time (connecting, changing passwords, running discovery), status messages keep the SPP UI informed about progress:
{ "Status": { "Type": "Changing", "Percent": 10, "Message": { "Name": "AssetConnecting", "Parameters": ["%Address%"] } } }Add these at key points in your operations:
"ChangePassword": {
"Do": [
{ "Status": { "Type": "Changing", "Percent": 10, "Message": { "Name": "AssetConnecting", "Parameters": ["%Address%"] } } },
{ "Function": { "Name": "LoginSsh", "Parameters": ["%FuncUserName%", "%FuncPassword%"] } },
{ "Status": { "Type": "Changing", "Percent": 40, "Message": { "Name": "ChangingPassword", "Parameters": ["%AccountUserName%"] } } },
...password change logic...,
{ "Disconnect": { "ConnectionObjectName": "SshConnection" } },
{ "Return": { "Value": true } }
]
}The Percent values give SPP a progress bar. The Message Name values are status message keys — see Status Messages Reference for the full list of built-in keys.
Upload and run through all operations:
# Create the platform (first time) or update the script (subsequent times)
New-SafeguardCustomPlatform -Name "MyCompletePlatform" -ScriptFile .\MyCompletePlatform.json
# Import-SafeguardCustomPlatformScript -PlatformToEdit "MyCompletePlatform" -ScriptFile .\MyCompletePlatform.json
# CheckSystem
Test-SafeguardAsset -AssetToTest "TestHost" -ExtendedLogging
# CheckPassword
Test-SafeguardAssetAccountPassword -AssetToUse "TestHost" -AccountToUse "testuser" -ExtendedLogging
# ChangePassword
Invoke-SafeguardAssetAccountPasswordChange -AssetToUse "TestHost" -AccountToUse "testuser"
# DiscoverAccounts — trigger from the web UI or wait for the configured scheduleReview logs with Get-SafeguardTaskLog after each test. With error handling in place, failures will show your custom log messages instead of raw exceptions.
Because your script contains these operations, SPP automatically derives feature flags:
| Flag | Set because |
|---|---|
PasswordFeatureFl |
CheckPassword is present |
AccountPasswordFl |
AccountPassword parameter (Secret type) is present |
AccountDiscoveryFl |
DiscoverAccounts is present |
You never configure these manually — they're derived from your script content. See Feature Flags for the full list.
From here you can extend your platform further:
- SSH key management — Add
CheckSshKey,ChangeSshKey,DiscoverAuthorizedKeys. See SSH Key Management Guide. - Host key discovery — Add
DiscoverSshHostKey. See the generic-linux-with-discovery sample. - Import libraries — Use
Importsto share functions across multiple platform scripts. See Imports Reference. - Dependent systems — Add
UpdateDependentSystemto propagate password changes. See the dependent systems template.
For a production-ready example of everything combined, study the GenericLinuxWithSSHKeySupport sample.