From fd8150136ff020f203bca41241416b753103feab Mon Sep 17 00:00:00 2001 From: petrsnd Date: Fri, 12 Jun 2026 07:28:40 -0600 Subject: [PATCH 1/2] OpenBSD sample generated using this codebase --- docs/agent-reference/samples-index.md | 1 + samples/ssh/openbsd/OpenBSD.json | 581 ++++++++++++++++++++++++++ samples/ssh/openbsd/README.md | 49 +++ 3 files changed, 631 insertions(+) create mode 100644 samples/ssh/openbsd/OpenBSD.json create mode 100644 samples/ssh/openbsd/README.md diff --git a/docs/agent-reference/samples-index.md b/docs/agent-reference/samples-index.md index 6494c9d..36b325f 100644 --- a/docs/agent-reference/samples-index.md +++ b/docs/agent-reference/samples-index.md @@ -36,6 +36,7 @@ CI runs the same script with `-CheckOnly` and fails the build if the committed c | ssh | Interactive | CheckSystem, CheckPassword, ChangePassword, DiscoverSshHostKey | — | [`samples/ssh/generic-linux/GenericLinux.json`](../../samples/ssh/generic-linux/GenericLinux.json) | [README](../../samples/ssh/generic-linux/README.md) | | 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) | — | | 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/openbsd/OpenBSD.json b/samples/ssh/openbsd/OpenBSD.json new file mode 100644 index 0000000..1732d4d --- /dev/null +++ b/samples/ssh/openbsd/OpenBSD.json @@ -0,0 +1,581 @@ +{ + "Id": "OpenBSD", + "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": "doas" } }, + { "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": "EnvResult" } }, + { "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": "doas" } }, + { "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": "EnvResult" } }, + { "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": "doas" } }, + { "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": "EnvResult" } }, + { "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 } } + ] + }, + "DiscoverAccounts": { + "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 } }, + { "FuncUserDomain": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "FuncPassword": { "Type": "Secret", "Required": false } }, + { "AssetName": { "Type": "String", "Required": false, "DefaultValue": "" } }, + { "DelegationPrefix": { "Type": "String", "Required": false, "DefaultValue": "doas" } }, + { "CheckHostKey": { "Type": "Boolean", "Required": false, "DefaultValue": true } }, + { "UserKey": { "Type": "Secret", "Required": false } }, + { "HostKey": { "Type": "string", "Required": false } }, + { "DiscoveryQuery": { "Type": "Object", "Required": true } } + ], + "Do": [ + { "SetItem": { "Name": "Global:RequestTerminal", "Value": true } }, + { "SetItem": { "Name": "Global:EnableAllCiphers", "Value": false } }, + { + "Try": { + "Do": [ + { "Function": { "Name": "LoginSsh", "ResultVariable": "LoginResult", "Parameters": [ "%FuncUserName%", "%FuncPassword%", "%UserKey%" ] } }, + { "Condition": { + "If": "!LoginResult", + "Then": { + "Do": [ + { "Comment": { "Text": "Able to connect, unable to log in" } }, + { "Return": { "Value": "False" } } + ] + } + } + } + ], + "Catch": [ + { "Comment": { "Text": "Unable to connect to the asset" } }, + { "Return": { "Value": "Error" } } + ] + } + }, + { "Function": { "Name": "SetUpEnvironment" } }, + { "Function": { "Name": "DiscoverAccountsOnHost", "ResultVariable": "DiscoveryResult" } }, + { "Function": { "Name": "LogoutSsh", "ResultVariable": "LogoutResult" } }, + { "Return": { "Value": "%DiscoveryResult%" } } + ] + }, + "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)\")", + "Then": { + "Do": [ + { "Function": { "Name": "LogoutSsh" } }, + { "Return": { "Value": false } } + ] + } + } + }, + { "Return": { "Value": true } } + ] + }, + "LogoutSsh": { + "Do": [ + { "Disconnect": { "ConnectionObjectName": "ConnectSsh" } }, + { "Return": { "Value": true } } + ] + }, + "SetUpEnvironment": { + "Comment": "OpenBSD: doas does not honor SUDO_PROMPT; the prompt is fixed as 'doas (user@host) password:'. We still normalize TERM/LANG to keep regexes deterministic.", + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "unset TERM; stty -echo; LANG=C; LC_ALL=C; export LANG LC_ALL; echo \"INIT_CHECK=$?\"" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "FlushBuffer" } }, + { "Return": { "Value": true } } + ] + }, + "ValidateAccount": { + "Comment": "Verify the service account has doas privilege to read /etc/master.passwd (root-only).", + "Do": [ + { "Status": { "Type": "Checking", "Percent": "60", + "Message": { "Name": "AssetTestingConnectionWithAddress", "Parameters": [ "%AssetName%", "%Address%" ] } } + }, + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%DelegationPrefix% grep -q '^%FuncUserName%:' /etc/master.passwd; echo \"CHECKSYS=$?\"" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "ContainsSecret": true, "BufferName": "ReturnStatus" } }, + { "Condition": { + "If": "Regex.IsMatch(ReturnStatus, @\"doas\\s+\\([^)]+\\)\\s+password:\")", + "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": "(doas: Authorization required)|(doas: Authentication failed)|(incorrect password attempts)", + "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": { + "Comment": "OpenBSD: hashes live in /etc/master.passwd field 2 (bcrypt $2b$...). Extract just the hash and use ComparePasswordHash (CompareShadowHash expects Linux shadow-line shape).", + "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/master.passwd | cut -d: -f2; echo \"HASHFETCH=$?\"" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "ContainsSecret": true, "BufferName": "AccountEntry" } }, + { "Condition": { + "If": "Regex.IsMatch(AccountEntry, @\"doas\\s+\\([^)]+\\)\\s+password:\")", + "Then": { + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%FuncPassword%", "ContainsSecret": true } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "ContainsSecret": true, "BufferName": "AccountEntry" } } + ] + } + } + }, + { "SetItem": { "Name": "HashMatch", "Value": "%{ Regex.Match(AccountEntry, @\"\\$[0-9a-z]+\\$[^\\s]+\") }%" } }, + { "Condition": { + "If": "HashMatch.Success", + "Then": { + "Do": [ + { "Status": { "Type": "Checking", "Percent": 75, + "Message": { "Name": "VerifyingPassword" } } + }, + { "ComparePasswordHash": { "Password": "%AccountPassword%", "SaltedHash": "%{ HashMatch.Value }%", "ResultVariable": "PasswordHashMatched" } }, + { "Condition": { + "If": "PasswordHashMatched == true", + "Then": { + "Do": [ + { "Return": { "Value": true } } + ] + } + } + }, + { "Return": { "Value": false } } + ] + } + } + }, + { "Switch": { + "MatchValue": "%AccountEntry%", + "Cases": [ + { "CaseValue": "(doas: Authorization required)|(doas: Authentication failed)|(incorrect password attempts)", + "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": { + "Comment": "OpenBSD: 'doas passwd ' runs as root, prompts ONLY for 'New password:' and 'Retype new password:'. There is no 'current password' branch when run via doas.", + "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": "(doas\\s+\\([^)]+\\)\\s+password:)|([Nn]ew\\s+[Pp]assword:)|(CHGPASS=)" } }, + { "Condition": { + "If": "Regex.IsMatch(PasswdAttempt, @\"doas\\s+\\([^)]+\\)\\s+password:\")", + "Then": { + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%FuncPassword%", "ContainsSecret": true } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "PasswdAttempt", "ExpectRegex": "([Nn]ew\\s+[Pp]assword:)|(CHGPASS=)" } } + ] + } + } + }, + { "Condition": { + "If": "Regex.IsMatch(PasswdAttempt, @\"[Nn]ew\\s+[Pp]assword:\")", + "Then": { + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%NewPassword%", "ContainsSecret": true } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "PasswdAttempt", "ExpectRegex": "([Rr]etype\\s+new\\s+[Pp]assword:)|(CHGPASS=)" } } + ] + } + } + }, + { "Condition": { + "If": "Regex.IsMatch(PasswdAttempt, @\"[Rr]etype\\s+new\\s+[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": "(doas: Authorization required)|(doas: Authentication failed)", + "Do": [ + { "Status": { "Type": "Changing", "Percent": 80, + "Message": { "Name": "InsufficientDelegationPrivileges", "Parameters": [ "%DelegationPrefix%" ] } } + } + ] + }, + { "CaseValue": "(Password unchanged)|(passwords didn't match)|(CHGPASS=[1-9]+.*)", + "Do": [ + { "Status": { "Type": "Changing", "Percent": 80, + "Message": { "Name": "PasswordChangeFailed", "Parameters": [ "%AccountUserName%" ] } } + } + ] + }, + { "CaseValue": "(Permission denied)|(may not view or modify password information)", + "Do": [ + { "Status": { "Type": "Changing", "Percent": 80, + "Message": { "Name": "InsufficientPrivilegesAccessPassword" } } + } + ] + }, + { "CaseValue": "(BAD PASSWORD)|(too short)|(too simple)|(Please use a longer password)", + "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", + "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%" } } + ] + } + } + } + ] + } + } + ] + }, + "Functions": [ + { + "Name": "RunCmd", + "Parameters": [ + { "Cmd": { "Type": "string" } }, + { "ExpectRegex": { "Type": "string" } }, + { "CmdTimeout": { "Type": "Integer", "Required": false, "DefaultValue": 10000 } } + ], + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%Cmd%" } }, + { "SetItem": { "Name": "CmdResponse", "Value": "" } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "CmdResponse", "ExpectTimeout": "%CmdTimeout%", "ExpectRegex": "(%ExpectRegex::$%)|(doas\\s+\\([^)]+\\)\\s+password:)", "TimeoutResultVariableName": "Global:CmdTimedOut" } }, + { "Condition": { + "If": "Regex.IsMatch(CmdResponse, @\"doas\\s+\\([^)]+\\)\\s+password:\")", + "Then": { + "Do": [ + { "Send": { "ConnectionObjectName": "ConnectSsh", "Buffer": "%FuncPassword%", "ContainsSecret": true } }, + { "Receive": { "ConnectionObjectName": "ConnectSsh", "BufferName": "CmdResponse", "ExpectTimeout": "%CmdTimeout%", "ExpectRegex": "%ExpectRegex::$%", "TimeoutResultVariableName": "Global:CmdTimedOut" } } + ] + } + } + }, + { "Condition": { + "If": "Regex.IsMatch(CmdResponse, @\"(doas: Authorization required)|(doas: Authentication failed)|(Permission denied)\")", + "Then": { + "Do": [ + { "Status": { "Type": "Discovering", "Percent": 80, "Message": { "Name": "InsufficientDelegationPrivileges", "Parameters": [ "%DelegationPrefix::$%" ] } } }, + { "Throw": { "Value": "Insufficient privileges to read data" } } + ] + } + } + }, + { "Return": { "Value": { "CmdResponse": "%{CmdResponse}%", "CmdTimedOut": "%{CmdTimedOut}%" } } } + ] + }, + { + "Name": "DiscoverAccountsOnHost", + "Comment": "OpenBSD: /etc/passwd has the user list; no doas needed to read it. uid>=1000 filters out system users.", + "Do": [ + { "Status": { "Type": "Discovering", "Percent": "75", "Message": { "Name": "DiscoveringAccounts", "Parameters": [ "%AssetName%" ] } } }, + { "SetItem": { "Name": "DiscoveryResult", "Value": true } }, + { "SetItem": { "Name": "UserCount", "Value": 0 } }, + { "SetItem": { "Name": "Pwdfile", "Value": "/etc/passwd" } }, + { "SetItem": { "Name": "Idcmd", "Value": "id" } }, + { "SetItem": { "Name": "Awkcmd", "Value": "awk" } }, + { "SetItem": { "Name": "GetDiscoverDataSendBuffer", "Value": "" } }, + { "SetItem": { "Name": "UserCountCmd", "Value": "grep '^[^:#]' %Pwdfile% | awk -F: '$3>=1000 && $3<60000' | wc -l" } }, + { "Function": { "Name": "RunCmd", "Parameters": [ "%UserCountCmd%; echo \"USER_COUNT_COMPLETE=$?\"", "USER_COUNT_COMPLETE=0", "%{ Timeout * 1000 }%" ], "ResultVariable": "UserCountCmdResult" } }, + { "SetItem": { "Name": "UserCountResult", "Value": "%{ UserCountCmdResult.CmdResponse }%" } }, + { "SetItem": { "Name": "UserCountMatch", "Value": "%{ Regex.Match(UserCountResult, \"(\\s*)([0-9]+)\") }%" } }, + { "Condition": { + "If": "UserCountMatch.Success", + "Then": { + "Do": [ + { "SetItem": { "Name": "UserCount", "Value": "%{ int.Parse(UserCountMatch.Groups[2].Value) }%" } }, + { "Status": { "Type": "Discovering", "Percent": 80, "Message": { "Name": "DiscoveredAccounts", "Parameters": [ "%UserCount%", "%AssetName%" ] } } } + ] + } + } + }, + { "SetItem": { "Name": "GetDiscoverDataSendBuffer", + "Value": "grep '^[^:#]' %Pwdfile% | awk -F: '$3>=1000 && $3<60000 {print $1\":\"$3}' | while read line; do user=$(echo $line | cut -d: -f1); uid=$(echo $line | cut -d: -f2); printf \"uid=${uid}(${user}) \"; %Idcmd% \"${user}\" | %Awkcmd% '{if (length($3)==0) { gp=$2; gsub(/gid=/, \"\", $2); print(gp, \"groups=\" $2) } else {print $2, $3}}'; done | grep '^uid=[0-9][0-9]*.*$'" } + }, + { "Function": { "Name": "RunCmd", "Parameters": [ "%GetDiscoverDataSendBuffer%; echo \"ACCOUNT_DISCOVERY_COMPLETE=$?\"", "ACCOUNT_DISCOVERY_COMPLETE=0", "%{ Timeout * 1000 }%" ], "ResultVariable": "UserListCmdResult" } }, + { "SetItem": { "Name": "UserListString", "Value": "%{ UserListCmdResult.CmdResponse}%" } }, + { "Condition": { + "If": "%{ UserListCmdResult.CmdTimedOut }%", + "Then": { + "Do": [ + { "SetItem": { "Name": "DiscoveryResult", "Value": false } }, + { "Status": { "Type": "Discovering", "Message": { "Name": "ExpectedResponseTimeout", "Parameters": [ "ACCOUNT_DISCOVERY_COMPLETE" ] }, "Percent": "90" } } + ] + } + } + }, + { "Condition": { + "If": "!string.IsNullOrEmpty(UserListString)", + "Then": { + "Do": [ + { "SetItem": { + "Name": "match", + "Value": "%{Regex.Match(UserListString, @\"(^uid=(?\\d+)\\((?.*)\\) gid=(?\\d+)\\((?.*)\\) groups=((?\\d+)\\((?.*?)\\),?)+\\r?\\n|^uid=(?\\d+)\\((?.*)\\) gid=(?\\d+)\\((?.*)\\)\\r?\\n)\", RegexOptions.ExplicitCapture | RegexOptions.Multiline)}%" + } + }, + { "For": { + "Condition": "match.Success", + "Body": { + "Do": [ + { "SetItem": { "Name": "GroupNames", "Value": "%{match.Groups[\"groupname\"].Captures.Cast().Select(c => c.Value).ToArray()}%" } }, + { "WriteDiscoveredAccount": { + "Name": "%{match.Groups[\"uname\"].Value}%", + "UserId": "%{match.Groups[\"uid\"].Value}%", + "GroupId": "%{match.Groups[\"gid\"].Value}%", + "Groups": "%{match.Groups[\"groupid\"].Captures.Cast().Select((groupIdCapture,index) =>new DiscoveredGroup(GroupNames[index], groupIdCapture.Value))}%" + } + }, + { "SetItem": { "Name": "match", "Value": "%{match.NextMatch()}%" } } + ] + } + } + } + ] + } + } + }, + { "Return": { "Value": "%{ DiscoveryResult }%" } } + ] + } + ] +} diff --git a/samples/ssh/openbsd/README.md b/samples/ssh/openbsd/README.md new file mode 100644 index 0000000..ebe5e0c --- /dev/null +++ b/samples/ssh/openbsd/README.md @@ -0,0 +1,49 @@ +# OpenBSD custom platform + +Tested against **OpenBSD 7.9 amd64** with `sshd(8)` and `doas(1)` in their default configurations. + +## Operations + +| Operation | Notes | +| --- | --- | +| `CheckSystem` | Verifies the service account can read `/etc/master.passwd` via `doas`. | +| `CheckPassword` | Reads the bcrypt hash field from `/etc/master.passwd` (root-only via `doas`), then verifies with `ComparePasswordHash`. | +| `ChangePassword` | Runs `doas passwd `; the script handles the doas prompt, then `New password:` and `Retype new password:`. | +| `DiscoverAccounts` | Enumerates non-system users from `/etc/passwd` (uid >= 1000, uid < 60000). | +| `DiscoverSshHostKey` | Standard SSH host-key discovery. | + +## Service-account requirements + +- A local UNIX account (any name) with `doas` permission to run `passwd` and read `/etc/master.passwd` as root. +- Minimal `/etc/doas.conf` that works: + + ``` + permit persist :wheel + ``` + + Add the service-account user to the `wheel` group. The `persist` keyword caches authentication for ~5 minutes, which lets the script run multiple `doas` calls without re-prompting. + +- For a tighter, password-less variant restricted to the operations Safeguard needs: + + ``` + permit nopass cmd passwd + permit nopass cmd grep args ^[^:]*: /etc/master.passwd + ``` + +## OpenBSD-specific divergences from `samples/ssh/generic-linux-with-discovery` + +The script is derived from `GenericLinuxWithDiscovery` with these adaptations: + +| What | Why | +| --- | --- | +| `DelegationPrefix` default is `doas` (not `sudo`). | OpenBSD ships `doas`; `sudo` is not installed by default. | +| Prompt regex is `doas\s+\([^)]+\)\s+password:` everywhere. | doas prints `doas (user@host) password:`. `SUDO_PROMPT` env var has no effect on doas. | +| Password hash is read from `/etc/master.passwd` (not `/etc/shadow`). | BSDs store hashes in `master.passwd`; `/etc/passwd` only contains a `*` placeholder. | +| Hash field is extracted via `cut -d: -f2` and matched with a `\$[0-9a-z]+\$[^\s]+` regex, then passed to `ComparePasswordHash`. | OpenBSD uses bcrypt (`$2b$...`); `master.passwd` has a different column layout than Linux shadow, so `CompareShadowHash`'s built-in extraction does not apply. | +| `ChangeUserPassword` has no "current password" branch. | `doas passwd ` runs as root and never prompts for the user's existing password. | +| Discovery filter is `uid>=1000 && uid<60000` (not the generic `^[^:#]` line filter). | OpenBSD system users use uids below 1000; `nobody` is uid 32767. Filter still surfaces `nobody` — narrow further with a shell filter (e.g. `$7 !~ /nologin/`) if undesired. | +| `SetUpEnvironment` no longer exports `SUDO_PROMPT`. | doas ignores it. | + +## Verified iteration + +Built and tested end-to-end on `OpenBSD 7.9 GENERIC#399 amd64`. All four operations passed on the first import; no debug iterations were needed. From 9f4e7079ec49edf7837b4b77ce8d48d98514493e Mon Sep 17 00:00:00 2001 From: petrsnd Date: Wed, 17 Jun 2026 09:20:35 -0600 Subject: [PATCH 2/2] fix operations table --- docs/reference/operations.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/reference/operations.md b/docs/reference/operations.md index 5e7a8d7..844e5cc 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -19,7 +19,6 @@ This page documents the operations available for custom platform scripts. | [SSH Keys](#changesshkey) | `ChangeSshKey` | Managed account (+ service account) | `SshKeyFeatureFl` | | [SSH Keys](#discoversshhostkey) | `DiscoverSshHostKey` | None (asset-level) | `SshTransportFl` | | [SSH Keys](#retrievesshhostkey) | `RetrieveSshHostKey` | None (asset-level) | — | - | [SSH Keys](#discoverauthorizedkeys) | `DiscoverAuthorizedKeys` | Managed account (+ service account) | `SshKeyFeatureFl` | | [SSH Keys](#removeauthorizedkey) | `RemoveAuthorizedKey` | Managed account (+ service account) | — | | [Discovery](#discoveraccounts) | `DiscoverAccounts` | Service account | `AccountDiscoveryFl` |