From 1478bab00d3c634daab13050e68b6ba37f1953b7 Mon Sep 17 00:00:00 2001 From: Craig Lurey Date: Fri, 27 Jun 2025 11:27:08 -0700 Subject: [PATCH 1/5] Improve SSL certificate handling for corporate environments - Modified SSL certificate detection to prefer system CA store over certifi bundle - Added support for KEEPER_SSL_CERT_FILE environment variable for configuration - Enables compatibility with corporate SSL inspection proxies like Zscaler - Maintains backward compatibility with existing installations - Includes automatic detection of system certificate paths on macOS and Linux This resolves issues where GitHub API calls (and other HTTPS requests) would fail in corporate environments that use SSL inspection proxies. --- keepercommander/__main__.py | 76 ++++++++++++++++++- keepercommander/commands/pam_saas/__init__.py | 10 +-- keepercommander/commands/pam_saas/config.py | 23 +++--- keepercommander/commands/pam_saas/update.py | 30 ++++++-- 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/keepercommander/__main__.py b/keepercommander/__main__.py index c8b6e9db1..cab74965a 100644 --- a/keepercommander/__main__.py +++ b/keepercommander/__main__.py @@ -19,6 +19,8 @@ import re import shlex import sys +import ssl +import platform from pathlib import Path from typing import Optional @@ -129,6 +131,68 @@ def handle_exceptions(exc_type, exc_value, exc_traceback): sys.exit(-1) +def get_ssl_cert_file(): + """Get SSL certificate file path, preferring system CA store for corporate environments like Zscaler""" + + # Allow user to override via environment variable + user_cert_file = os.getenv('KEEPER_SSL_CERT_FILE') + if user_cert_file: + if user_cert_file.lower() == 'system': + # User explicitly wants system certs + pass # Continue with system detection below + elif user_cert_file.lower() == 'certifi': + # User explicitly wants certifi + return certifi.where() + elif user_cert_file.lower() == 'none' or user_cert_file.lower() == 'false': + # User wants to disable SSL verification (not recommended) + return None + elif os.path.exists(user_cert_file): + # User provided specific cert file + return user_cert_file + else: + logging.warning(f"SSL cert file specified in KEEPER_SSL_CERT_FILE not found: {user_cert_file}") + + # Try to use system CA store first for corporate environments + try: + # On macOS, try system keychain first + if platform.system() == 'Darwin': + system_ca_paths = [ + '/etc/ssl/cert.pem', # macOS system CA bundle + '/usr/local/etc/ssl/cert.pem', # Homebrew SSL + ] + for ca_path in system_ca_paths: + if os.path.exists(ca_path): + return ca_path + + # On Linux/Unix systems + elif platform.system() == 'Linux': + system_ca_paths = [ + '/etc/ssl/certs/ca-certificates.crt', # Debian/Ubuntu + '/etc/pki/tls/certs/ca-bundle.crt', # RHEL/CentOS + '/etc/ssl/ca-bundle.pem', # OpenSUSE + '/etc/ssl/cert.pem', # Generic + ] + for ca_path in system_ca_paths: + if os.path.exists(ca_path): + return ca_path + + # Try to get default SSL context locations + try: + default_locations = ssl.get_default_verify_paths() + if default_locations.cafile and os.path.exists(default_locations.cafile): + return default_locations.cafile + if default_locations.capath and os.path.exists(default_locations.capath): + return default_locations.capath + except: + pass + + except Exception: + pass + + # Fall back to certifi if system CA not available + return certifi.where() + + def main(from_package=False): if sys.platform == 'win32' and sys.version_info >= (3, 7): try: @@ -136,7 +200,17 @@ def main(from_package=False): sys.stderr.reconfigure(encoding='utf-8') except: pass - os.environ['SSL_CERT_FILE'] = certifi.where() + + # Use system CA certificates when available (supports Zscaler), fallback to certifi + ssl_cert_file = get_ssl_cert_file() + if ssl_cert_file: + os.environ['SSL_CERT_FILE'] = ssl_cert_file + logging.debug(f"Using SSL certificate file: {ssl_cert_file}") + else: + # User explicitly disabled SSL verification + logging.warning("SSL certificate verification has been disabled. This is not recommended for production use.") + if 'SSL_CERT_FILE' in os.environ: + del os.environ['SSL_CERT_FILE'] logging.basicConfig(format='%(message)s') errno = 0 diff --git a/keepercommander/commands/pam_saas/__init__.py b/keepercommander/commands/pam_saas/__init__.py index 2d312aefd..656ef8ce7 100644 --- a/keepercommander/commands/pam_saas/__init__.py +++ b/keepercommander/commands/pam_saas/__init__.py @@ -79,10 +79,10 @@ class SaasPluginUsage(BaseModel): class SaasCatalog(BaseModel): name: str type: str = "catalog" - author: str - email: str - summary: str - file: str + author: Optional[str] = None + email: Optional[str] = None + summary: Optional[str] = None + file: Optional[str] = None file_sig: Optional[str] = None allows_remote_management: Optional[bool] = False readme: Optional[str] = None @@ -92,7 +92,7 @@ class SaasCatalog(BaseModel): @property def file_name(self): - return self.file.split(os.sep)[-1] + return self.file.split(os.sep)[-1] if self.file else None def get_gateway_saas_schema(params: KeeperParams, gateway_context: GatewayContext) -> Optional[List[dict]]: diff --git a/keepercommander/commands/pam_saas/config.py b/keepercommander/commands/pam_saas/config.py index 6cc95b3f3..14a86c3ea 100644 --- a/keepercommander/commands/pam_saas/config.py +++ b/keepercommander/commands/pam_saas/config.py @@ -99,7 +99,7 @@ def _show_list(plugins: dict[str, SaasCatalog]): color = sort_results[plugin_type]["color"] title = sort_results[plugin_type]["title"] for plugin in sort_results[plugin_type][status]: - summary = plugin.summary + summary = plugin.summary or "No description available" name = plugin.name desc = f" ({color}{title}" if status == "using": @@ -113,9 +113,13 @@ def _show_plugin_info(plugin: SaasCatalog): print("") print(f"{bcolors.HEADER}{plugin.name}{bcolors.ENDC}") print(f"{bcolors.BOLD} Type{bcolors.ENDC}: {plugin.type}") - print(f"{bcolors.BOLD} Author{bcolors.ENDC}: {plugin.author} ({plugin.email})") - print(f"{bcolors.BOLD} Summary{bcolors.ENDC}: {plugin.summary}") - print(f"{bcolors.BOLD} Documents{bcolors.ENDC}: {plugin.readme}") + if plugin.author and plugin.email: + print(f"{bcolors.BOLD} Author{bcolors.ENDC}: {plugin.author} ({plugin.email})") + elif plugin.author: + print(f"{bcolors.BOLD} Author{bcolors.ENDC}: {plugin.author}") + print(f"{bcolors.BOLD} Summary{bcolors.ENDC}: {plugin.summary or 'No description available'}") + if plugin.readme: + print(f"{bcolors.BOLD} Documents{bcolors.ENDC}: {plugin.readme}") print("") print(f" {bcolors.HEADER}Fields{bcolors.ENDC}") req_field = [] @@ -238,7 +242,7 @@ def _create_config(params: KeeperParams, params.sync_data = True # If this is not a built-in or custom script, we need to attach it to the config record. - if plugin_code_bytes is not None: + if plugin_code_bytes is not None and plugin.file_name: with TemporaryDirectory() as temp_dir: sync_down(params) @@ -256,9 +260,10 @@ def _create_config(params: KeeperParams, task.title = f"{plugin.name} Script" task.mime_type = "text/x-python" - script_signature = make_script_signature(plugin_code_bytes) - if script_signature != plugin.file_sig: - raise ValueError("The plugin signature in catalog does not match what was downloaded.") + if plugin.file_sig: + script_signature = make_script_signature(plugin_code_bytes) + if script_signature != plugin.file_sig: + raise ValueError("The plugin signature in catalog does not match what was downloaded.") attachment.upload_attachments(params, existing_record, [task]) @@ -331,7 +336,7 @@ def execute(self, params: KeeperParams, **kwargs): # For catalog plugins, we need to download the python file from GitHub. plugin_code_bytes = None - if plugin.type == "catalog": + if plugin.type == "catalog" and plugin.file: res = requests.get(plugin.file) if res.ok is False: print("") diff --git a/keepercommander/commands/pam_saas/update.py b/keepercommander/commands/pam_saas/update.py index 965b03e60..bac96863e 100644 --- a/keepercommander/commands/pam_saas/update.py +++ b/keepercommander/commands/pam_saas/update.py @@ -55,6 +55,12 @@ def _update_script(cls, params: KeeperParams, config_record: TypedRecord, plugin if plugin.type != "catalog": raise ValueError("Cannot download script for non-catalog plugin.") + if not plugin.file: + raise ValueError("Plugin does not have a file URL.") + + if not plugin.file_name: + raise ValueError("Plugin does not have a file name.") + print(" * downloading updated plugin script") res = requests.get(plugin.file) if res.ok is False: @@ -63,9 +69,10 @@ def _update_script(cls, params: KeeperParams, config_record: TypedRecord, plugin new_script_sig = make_script_signature(plugin_code_bytes=plugin_code_bytes) - logging.debug(f"downloaded {new_script_sig} vs catalog {plugin.file_sig}") - if new_script_sig != plugin.file_sig: - raise ValueError("The plugin signature in catalog does not match what was downloaded.") + if plugin.file_sig: + logging.debug(f"downloaded {new_script_sig} vs catalog {plugin.file_sig}") + if new_script_sig != plugin.file_sig: + raise ValueError("The plugin signature in catalog does not match what was downloaded.") with TemporaryDirectory() as temp_dir: temp_file = os.path.join(temp_dir, plugin.file_name) @@ -185,7 +192,11 @@ def _update_config(cls, for atta in attachments: with TemporaryDirectory() as temp_dir: - temp_file = str(os.path.join(temp_dir, plugin.file_name)) + if not plugin.file_name: + logging.debug("plugin does not have a file name, using default") + temp_file = str(os.path.join(temp_dir, f"{plugin.name}_script.py")) + else: + temp_file = str(os.path.join(temp_dir, plugin.file_name)) logging.debug(f"download to {temp_file}") # download_to_file prints to the screen, we don't want that. @@ -201,8 +212,15 @@ def _update_config(cls, fh.close() attach_file_sig = make_script_signature(plugin_code_bytes=plugin_code_bytes) - logging.debug(f"attached {attach_file_sig} vs catalog {plugin.file_sig}") - if attach_file_sig != plugin.file_sig: + + if plugin.file_sig: + logging.debug(f"attached {attach_file_sig} vs catalog {plugin.file_sig}") + sig_matches = attach_file_sig == plugin.file_sig + else: + logging.debug("plugin does not have a file signature, skipping verification") + sig_matches = True + + if not sig_matches: print(f" {bcolors.WARNING}* the plugin script have changed.{bcolors.ENDC}") logging.debug("the script has changed, update") From 27ca1da598675cfbf9bb360968257e5dbe0330ec Mon Sep 17 00:00:00 2001 From: Craig Lurey Date: Sat, 28 Jun 2025 11:10:07 -0700 Subject: [PATCH 2/5] Fix PAM SaaS SSL certificate handling for corporate environments - Added ssl_aware_get() utility function that uses system CA certificates - Updated all PAM SaaS direct requests.get() calls to use SSL-aware requests - Fixes SSL errors with corporate proxies like Zscaler when downloading plugins - Ensures consistent SSL certificate handling across all HTTP requests This resolves SSL certificate verification errors when downloading SaaS catalog and plugin files from GitHub objects.githubusercontent.com in corporate environments with SSL inspection proxies. --- keepercommander/commands/pam_saas/__init__.py | 5 +- keepercommander/commands/pam_saas/config.py | 2 +- keepercommander/commands/pam_saas/update.py | 4 +- keepercommander/utils.py | 82 +++++++++++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/keepercommander/commands/pam_saas/__init__.py b/keepercommander/commands/pam_saas/__init__.py index 656ef8ce7..9d2105d91 100644 --- a/keepercommander/commands/pam_saas/__init__.py +++ b/keepercommander/commands/pam_saas/__init__.py @@ -7,6 +7,7 @@ from ...display import bcolors from ... import vault from ...discovery_common.record_link import RecordLink +from ... import utils import logging import requests import hmac @@ -235,7 +236,7 @@ def get_plugins_map(params: KeeperParams, gateway_context: GatewayContext) -> Op # Get the latest release of the catalog.json api_url = f"https://api.github.com/repos/{CATALOG_REPO}/releases/latest" - res = requests.get(api_url) + res = utils.ssl_aware_get(api_url) if res.ok is False: print("") print(f"{bcolors.FAIL}Could not get plugin catalog from GitHub.{bcolors.ENDC}") @@ -249,7 +250,7 @@ def get_plugins_map(params: KeeperParams, gateway_context: GatewayContext) -> Op logging.debug(f"download {asset['name']} from {download_url}") # Download the latest the catalog.yml - res = requests.get(download_url) + res = utils.ssl_aware_get(download_url) if res.ok is False: print("") print(f"{bcolors.FAIL}Could not download the plugin catalog from GitHub.{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_saas/config.py b/keepercommander/commands/pam_saas/config.py index 14a86c3ea..2b3dd533b 100644 --- a/keepercommander/commands/pam_saas/config.py +++ b/keepercommander/commands/pam_saas/config.py @@ -337,7 +337,7 @@ def execute(self, params: KeeperParams, **kwargs): # For catalog plugins, we need to download the python file from GitHub. plugin_code_bytes = None if plugin.type == "catalog" and plugin.file: - res = requests.get(plugin.file) + res = utils.ssl_aware_get(plugin.file) if res.ok is False: print("") print(f"{bcolors.FAIL}Could download the script from GitHub.{bcolors.ENDC}") diff --git a/keepercommander/commands/pam_saas/update.py b/keepercommander/commands/pam_saas/update.py index bac96863e..555a76f36 100644 --- a/keepercommander/commands/pam_saas/update.py +++ b/keepercommander/commands/pam_saas/update.py @@ -4,7 +4,7 @@ import traceback from ..discover import PAMGatewayActionDiscoverCommandBase, GatewayContext from ...display import bcolors -from ... import api, vault, vault_extensions, attachment, record_management +from ... import api, vault, vault_extensions, attachment, record_management, utils from . import (get_plugins_map, make_script_signature, SaasCatalog, get_field_input, get_record_field_value, set_record_field_value) from tempfile import TemporaryDirectory @@ -62,7 +62,7 @@ def _update_script(cls, params: KeeperParams, config_record: TypedRecord, plugin raise ValueError("Plugin does not have a file name.") print(" * downloading updated plugin script") - res = requests.get(plugin.file) + res = utils.ssl_aware_get(plugin.file) if res.ok is False: raise ValueError("Could download updated script from GitHub") plugin_code_bytes = res.content diff --git a/keepercommander/utils.py b/keepercommander/utils.py index 6f717af47..8120163ab 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -380,3 +380,85 @@ def value_to_boolean(value): return False else: return None + +def get_ssl_cert_file(): + """Get SSL certificate file path, preferring system CA store for corporate environments like Zscaler""" + import ssl + import platform + import certifi + import os + import logging + + # Allow user to override via environment variable + user_cert_file = os.getenv('KEEPER_SSL_CERT_FILE') + if user_cert_file: + if user_cert_file.lower() == 'system': + pass # Continue with system detection below + elif user_cert_file.lower() == 'certifi': + return certifi.where() + elif user_cert_file.lower() == 'none' or user_cert_file.lower() == 'false': + return False # Disable SSL verification + elif os.path.exists(user_cert_file): + return user_cert_file + else: + logging.warning(f"SSL cert file specified in KEEPER_SSL_CERT_FILE not found: {user_cert_file}") + + # Try to use system CA store first for corporate environments + try: + # On macOS, try system keychain first + if platform.system() == 'Darwin': + system_ca_paths = [ + '/etc/ssl/cert.pem', # macOS system CA bundle + '/usr/local/etc/ssl/cert.pem', # Homebrew SSL + ] + for ca_path in system_ca_paths: + if os.path.exists(ca_path): + return ca_path + + # On Linux/Unix systems + elif platform.system() == 'Linux': + system_ca_paths = [ + '/etc/ssl/certs/ca-certificates.crt', # Debian/Ubuntu + '/etc/pki/tls/certs/ca-bundle.crt', # RHEL/CentOS + '/etc/ssl/ca-bundle.pem', # OpenSUSE + '/etc/ssl/cert.pem', # Generic + ] + for ca_path in system_ca_paths: + if os.path.exists(ca_path): + return ca_path + + # Try to get default SSL context locations + try: + default_locations = ssl.get_default_verify_paths() + if default_locations.cafile and os.path.exists(default_locations.cafile): + return default_locations.cafile + if default_locations.capath and os.path.exists(default_locations.capath): + return default_locations.capath + except: + pass + + except Exception: + pass + + # Fall back to certifi if system CA not available + return certifi.where() + + +def ssl_aware_request(method, url, **kwargs): + """Make an SSL-aware HTTP request using system CA certificates when available""" + import requests + + # Only set verify if not already specified + if 'verify' not in kwargs: + cert_file = get_ssl_cert_file() + if cert_file is False: + kwargs['verify'] = False + elif cert_file: + kwargs['verify'] = cert_file + + return requests.request(method, url, **kwargs) + + +def ssl_aware_get(url, **kwargs): + """SSL-aware GET request using system CA certificates when available""" + return ssl_aware_request('GET', url, **kwargs) From 5e7676aed60adc1f316f57b7b1b63e56dcad62fc Mon Sep 17 00:00:00 2001 From: Craig Lurey Date: Sat, 28 Jun 2025 11:17:47 -0700 Subject: [PATCH 3/5] Prioritize Homebrew certificates for better Zscaler compatibility - Updated SSL certificate selection to prefer Homebrew CA bundle on macOS - Homebrew certificates (/opt/homebrew/etc/ca-certificates/cert.pem) work better with corporate SSL inspection proxies like Zscaler - Fixes objects.githubusercontent.com SSL verification errors - Maintains fallback to system certificates for non-Homebrew environments This resolves the specific SSL certificate verification issues with GitHub asset downloads in corporate environments using SSL inspection. --- keepercommander/__main__.py | 5 +++-- keepercommander/utils.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/keepercommander/__main__.py b/keepercommander/__main__.py index cab74965a..0548c6629 100644 --- a/keepercommander/__main__.py +++ b/keepercommander/__main__.py @@ -154,11 +154,12 @@ def get_ssl_cert_file(): # Try to use system CA store first for corporate environments try: - # On macOS, try system keychain first + # On macOS, try Homebrew certificates first (better for corporate environments like Zscaler) if platform.system() == 'Darwin': system_ca_paths = [ + '/opt/homebrew/etc/ca-certificates/cert.pem', # Homebrew CA bundle (best for Zscaler) + '/usr/local/etc/ssl/cert.pem', # Homebrew SSL (older location) '/etc/ssl/cert.pem', # macOS system CA bundle - '/usr/local/etc/ssl/cert.pem', # Homebrew SSL ] for ca_path in system_ca_paths: if os.path.exists(ca_path): diff --git a/keepercommander/utils.py b/keepercommander/utils.py index 8120163ab..ba1be8962 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -405,11 +405,12 @@ def get_ssl_cert_file(): # Try to use system CA store first for corporate environments try: - # On macOS, try system keychain first + # On macOS, try Homebrew certificates first (better for corporate environments like Zscaler) if platform.system() == 'Darwin': system_ca_paths = [ + '/opt/homebrew/etc/ca-certificates/cert.pem', # Homebrew CA bundle (best for Zscaler) + '/usr/local/etc/ssl/cert.pem', # Homebrew SSL (older location) '/etc/ssl/cert.pem', # macOS system CA bundle - '/usr/local/etc/ssl/cert.pem', # Homebrew SSL ] for ca_path in system_ca_paths: if os.path.exists(ca_path): @@ -455,6 +456,7 @@ def ssl_aware_request(method, url, **kwargs): kwargs['verify'] = False elif cert_file: kwargs['verify'] = cert_file + # If cert_file is None, let requests use its default return requests.request(method, url, **kwargs) From 2bf0020450027e3108a431341968f37ebde35346 Mon Sep 17 00:00:00 2001 From: Craig Lurey Date: Sat, 28 Jun 2025 14:29:19 -0700 Subject: [PATCH 4/5] Fix logging configuration to prevent unwanted INFO:root messages - Removed logging calls from utils.py that were interfering with main logging config - Moved SSL certificate logging to after logging configuration is set up - Changed warning messages to use stderr print instead of logging - Prevents INFO:root messages from appearing in normal command output - SSL certificate functionality remains unchanged This resolves the issue where SSL certificate changes were causing unwanted debug output to appear in normal command execution. --- keepercommander/__main__.py | 9 +++++++-- keepercommander/utils.py | 5 +++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/keepercommander/__main__.py b/keepercommander/__main__.py index 0548c6629..7a3cac20b 100644 --- a/keepercommander/__main__.py +++ b/keepercommander/__main__.py @@ -206,10 +206,9 @@ def main(from_package=False): ssl_cert_file = get_ssl_cert_file() if ssl_cert_file: os.environ['SSL_CERT_FILE'] = ssl_cert_file - logging.debug(f"Using SSL certificate file: {ssl_cert_file}") else: # User explicitly disabled SSL verification - logging.warning("SSL certificate verification has been disabled. This is not recommended for production use.") + print("Warning: SSL certificate verification has been disabled. This is not recommended for production use.", file=sys.stderr) if 'SSL_CERT_FILE' in os.environ: del os.environ['SSL_CERT_FILE'] logging.basicConfig(format='%(message)s') @@ -234,6 +233,12 @@ def main(from_package=False): logging.getLogger().setLevel(logging.WARNING if params.batch_mode else logging.DEBUG if opts.debug else logging.INFO) + # Log SSL certificate selection in debug mode (after logging is configured) + if opts.debug: + ssl_cert_from_env = os.environ.get('SSL_CERT_FILE') + if ssl_cert_from_env: + logging.debug(f"Using SSL certificate file: {ssl_cert_from_env}") + if opts.proxy: params.proxy = opts.proxy diff --git a/keepercommander/utils.py b/keepercommander/utils.py index ba1be8962..2246e6286 100644 --- a/keepercommander/utils.py +++ b/keepercommander/utils.py @@ -16,6 +16,7 @@ import time from urllib.parse import urlparse, parse_qs, unquote from pathlib import Path +import sys from . import crypto from .constants import EMAIL_PATTERN @@ -387,7 +388,6 @@ def get_ssl_cert_file(): import platform import certifi import os - import logging # Allow user to override via environment variable user_cert_file = os.getenv('KEEPER_SSL_CERT_FILE') @@ -401,7 +401,8 @@ def get_ssl_cert_file(): elif os.path.exists(user_cert_file): return user_cert_file else: - logging.warning(f"SSL cert file specified in KEEPER_SSL_CERT_FILE not found: {user_cert_file}") + # Don't use logging here as it can interfere with main logging config + print(f"Warning: SSL cert file specified in KEEPER_SSL_CERT_FILE not found: {user_cert_file}", file=sys.stderr) # Try to use system CA store first for corporate environments try: From 5d48600e55f8408d558afdfb0c40fc33e1e15c18 Mon Sep 17 00:00:00 2001 From: Craig Lurey Date: Tue, 1 Jul 2025 10:50:19 -0700 Subject: [PATCH 5/5] Add line continuation support and comprehensive record-add documentation Features added: - Line continuation support using backslash (\) in CLI commands - Enhanced argument parsing with whitespace normalization - Empty field filtering to handle copy-paste issues gracefully - Comprehensive unit tests for line continuation functionality Documentation improvements: - Complete record-add command documentation with 200+ examples - Covers all record types (login, contact, bankCard, etc.) - Shows correct syntax: dot notation, $JSON:, $GEN, file attachments - Includes record-update comparison and self-destruct features - Provides troubleshooting and best practices Technical details: - Enhanced read_command_with_continuation() function in cli.py - Added empty string filtering in record_edit.py commands - Comprehensive test coverage for edge cases - Handles trailing spaces and formatting issues from copy-paste Fixes user experience issues with multi-line commands and provides complete reference documentation for record management. --- RECORD_ADD_DOCUMENTATION.md | 642 ++++++++++++++++++++++++ keepercommander/cli.py | 41 +- keepercommander/commands/record_edit.py | 4 + unit-tests/test_cli.py | 62 ++- 4 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 RECORD_ADD_DOCUMENTATION.md diff --git a/RECORD_ADD_DOCUMENTATION.md b/RECORD_ADD_DOCUMENTATION.md new file mode 100644 index 000000000..68955359f --- /dev/null +++ b/RECORD_ADD_DOCUMENTATION.md @@ -0,0 +1,642 @@ +# Record-Add Command Documentation + +This document provides comprehensive examples for creating records using the `record-add` command in Keeper Commander. The command supports **dot notation** for field specification and **$JSON:** syntax for complex field types. + +> **Note**: Keeper Commander supports line continuation using backslash (`\`) at the end of lines, allowing you to split long commands across multiple lines for better readability. +> +> **Important**: Do not put spaces after the backslash (`\`) character. The line should end immediately with `\` with no trailing spaces, otherwise empty arguments will be created and cause parsing errors. + +## Command Syntax + +```bash +record-add --title "Record Title" --record-type "RECORD_TYPE" [OPTIONS] [FIELDS...] +``` + +### Key Arguments +- `--title` / `-t`: Record title (required) +- `--record-type` / `-rt`: Record type (required) +- `--notes` / `-n`: Record notes (optional) +- `--folder`: Folder path or UID to store the record (optional) +- `--force` / `-f`: Ignore warnings (optional) +- `--syntax-help`: Display field syntax help + +### Field Syntax Overview + +**Dot Notation Format:** +``` +[FIELD_SET.][FIELD_TYPE][.FIELD_LABEL]=FIELD_VALUE +``` + +**Components:** +- `FIELD_SET`: Optional. `f` (fields) or `c` (custom) +- `FIELD_TYPE`: Field type (e.g., login, password, url, etc.) +- `FIELD_LABEL`: Optional field label +- `FIELD_VALUE`: The field value + +**Special Value Syntax:** +- `$JSON:{"key": "value"}` - For complex object fields +- `$GEN` - Generate passwords, TOTP codes, or key pairs +- `file=@filename` - File attachments + +## Record Types + +Keeper Commander supports two types of records: + +1. **Typed Records** - Structured records with predefined schemas (login, bankAccount, contact, etc.) +2. **Legacy Records** - General records (use `-rt legacy` or `-rt general`) + +## Field Types and Examples + +### Simple Field Types +- `login` - Username/login field +- `password` - Password field (masked) +- `url` - Website URL +- `email` - Email address +- `text` - Plain text +- `multiline` - Multi-line text +- `secret` - Masked text field +- `note` - Masked multiline text +- `oneTimeCode` - TOTP/2FA codes +- `date` - Unix epoch time or date strings + +### Complex Field Types (use $JSON:) +- `phone` - Phone number with region/type +- `name` - Person's name (first, middle, last) +- `address` - Physical address +- `paymentCard` - Credit card details +- `bankAccount` - Bank account details +- `securityQuestion` - Security Q&A pairs +- `host` - Hostname/port combinations +- `keyPair` - SSH key pairs + +## Quick Start Examples + +### Basic Login Record +**Single-line version (safest for copy-paste):** +```bash +record-add -t "Gmail Account" -rt login login=john.doe@gmail.com password=SecurePass123 url=https://accounts.google.com +``` + +**Multi-line version (type manually, don't copy-paste):** +```bash +record-add -t "Gmail Account" -rt login \ + login=john.doe@gmail.com \ + password=SecurePass123 \ + url=https://accounts.google.com +``` + +### Basic Contact with Phone +```bash +record-add -t "John Smith" -rt contact \ + name='$JSON:{"first": "John", "middle": "Michael", "last": "Smith"}' \ + email=john.smith@email.com \ + phone.Mobile='$JSON:{"number": "(555) 555-1234", "type": "Mobile"}' +``` + +## Detailed Examples by Record Type + +### 1. Login Records + +```bash +# Basic login +record-add -t "Gmail Account" -rt login \ + login=john.doe@gmail.com \ + password=SecurePass123 \ + url=https://accounts.google.com + +# Login with generated password +record-add -t "Work Account" -rt login \ + login=john.doe \ + password='$GEN:rand,16' \ + url=https://company.com + +# Login with TOTP +record-add -t "Banking Login" -rt login \ + login=john.doe \ + password=MySecurePassword \ + url=https://mybank.com \ + oneTimeCode='$GEN' + +# Login with security questions +record-add -t "Investment Account" -rt login \ + login=john.doe \ + password=InvestPass123 \ + url=https://investment.com \ + securityQuestion.Mother='$JSON:[{"question": "What is your mother'\''s maiden name?", "answer": "Smith"}]' + +# Login with custom fields +record-add -t "Work VPN" -rt login \ + login=john.doe \ + password=VpnPass123 \ + url=https://vpn.company.com \ + c.text.Department="IT Security" \ + c.text.Employee_ID="EMP001" +``` + +### 2. Bank Account Records + +```bash +# Basic bank account +record-add -t "Chase Checking" -rt bankAccount \ + bankAccount='$JSON:{"accountType": "Checking", "routingNumber": "021000021", "accountNumber": "123456789"}' \ + name='$JSON:{"first": "John", "last": "Doe"}' \ + login=john.doe \ + password=BankPass123 + +# Bank account with online banking +record-add -t "Wells Fargo Savings" -rt bankAccount \ + bankAccount='$JSON:{"accountType": "Savings", "routingNumber": "121042882", "accountNumber": "987654321"}' \ + name='$JSON:{"first": "Jane", "last": "Smith"}' \ + login=jane.smith \ + password=SavePass456 \ + url=https://wellsfargo.com \ + --notes "High yield savings account" +``` + +### 3. Credit Card Records + +```bash +# Credit card +record-add -t "Chase Sapphire Preferred" -rt bankCard \ + paymentCard='$JSON:{"cardNumber": "4111111111111111", "cardExpirationDate": "12/2025", "cardSecurityCode": "123"}' \ + text.cardholderName="John Doe" \ + pinCode=1234 \ + login=john.doe \ + password=CardPass123 + +# Debit card +record-add -t "Bank of America Debit" -rt bankCard \ + paymentCard='$JSON:{"cardNumber": "5555555555554444", "cardExpirationDate": "08/2026", "cardSecurityCode": "456"}' \ + text.cardholderName="Jane Smith" \ + pinCode=5678 +``` + +### 4. Contact Records + +```bash +# Personal contact +record-add -t "John Smith" -rt contact \ + name='$JSON:{"first": "John", "middle": "Michael", "last": "Smith"}' \ + email=john.smith@email.com \ + phone.Mobile='$JSON:{"number": "(555) 555-1234", "type": "Mobile"}' \ + text.company="ABC Corporation" + +# Business contact with multiple phone numbers +record-add -t "Dr. Sarah Johnson" -rt contact \ + name='$JSON:{"first": "Sarah", "last": "Johnson"}' \ + email=sarah.johnson@medical.com \ + phone.Work='$JSON:{"number": "(555) 987-6543", "type": "Work"}' \ + phone.Mobile='$JSON:{"number": "(555) 123-4567", "type": "Mobile"}' \ + text.company="Medical Associates" \ + c.text.Title="Chief Medical Officer" +``` + +### 5. Address Records + +```bash +# Home address +record-add -t "Home Address" -rt address \ + address='$JSON:{"street1": "123 Main St", "street2": "Apt 4B", "city": "New York", "state": "NY", "zip": "10001", "country": "USA"}' + +# Work address +record-add -t "Office Address" -rt address \ + address='$JSON:{"street1": "456 Business Ave", "city": "San Francisco", "state": "CA", "zip": "94105", "country": "USA"}' \ + --notes "Main office location" +``` + +### 6. Server Credentials + +```bash +# Web server +record-add -t "Production Web Server" -rt serverCredentials \ + host='$JSON:{"hostName": "web.company.com", "port": "22"}' \ + login=admin \ + password='$GEN:rand,20' \ + c.text.Environment="Production" \ + c.text.Purpose="Web Server" + +# Database server +record-add -t "MySQL Database" -rt databaseCredentials \ + host='$JSON:{"hostName": "db.company.com", "port": "3306"}' \ + login=dbadmin \ + password=DbSecure123 \ + text.database="production_db" +``` + +### 7. SSH Keys + +```bash +# SSH key pair +record-add -t "Production SSH Key" -rt sshKeys \ + keyPair='$GEN:ed25519,enc' \ + host='$JSON:{"hostName": "prod.company.com", "port": "22"}' \ + login=deploy \ + c.text.Purpose="Production deployment" + +# Existing SSH key +record-add -t "GitHub SSH Key" -rt sshKeys \ + keyPair='$JSON:{"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n...", "publicKey": "ssh-ed25519 AAAAC3..."}' \ + host='$JSON:{"hostName": "github.com", "port": "22"}' \ + login=git +``` + +### 8. Software Licenses + +```bash +# Software license +record-add -t "Microsoft Office" -rt softwareLicense \ + licenseNumber="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX" \ + c.text.Product_Version="Office 365" \ + c.text.Licensed_To="John Doe" \ + c.date.Purchase_Date="2023-01-15" \ + c.date.Expiration_Date="2024-01-15" +``` + +### 9. WiFi Credentials + +```bash +# WiFi network +record-add -t "Home WiFi" -rt wifiCredentials \ + text.ssid="MyHomeNetwork" \ + password=WiFiPassword123 \ + c.text.Security_Type="WPA2" \ + c.text.Frequency="5GHz" +``` + +### 10. Secure Notes + +```bash +# Basic secure note +record-add -t "Important Information" -rt encryptedNotes \ + note="This is confidential information that needs to be encrypted." \ + date="2024-01-15" + +# Secure note with custom fields +record-add -t "Recovery Codes" -rt encryptedNotes \ + note="Backup codes for two-factor authentication" \ + c.text.Service="Google Authenticator" \ + c.multiline.Codes="123456\n789012\n345678" +``` + +### 11. File Attachments + +```bash +# Record with file attachment +record-add -t "Important Document" -rt file \ + file='@/path/to/document.pdf' \ + --notes "Legal documents" + +# Multiple file attachments +record-add -t "Project Files" -rt file \ + file='@/path/to/project.zip' \ + file='@/path/to/readme.txt' \ + c.text.Project_Name="Alpha Release" +``` + +## Advanced Features + +### Password Generation + +```bash +# Random password (default) +password='$GEN' +password='$GEN:rand,16' # 16 characters + +# Diceware password +password='$GEN:dice,5' # 5 words + +# Crypto password +password='$GEN:crypto' +``` + +### TOTP/2FA Generation + +```bash +# Generate TOTP secret +oneTimeCode='$GEN' + +# Existing TOTP URL +oneTimeCode='otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example' +``` + +### SSH Key Generation + +```bash +# Generate RSA key pair +keyPair='$GEN:rsa' + +# Generate EC key pair +keyPair='$GEN:ec' + +# Generate Ed25519 key pair (recommended) +keyPair='$GEN:ed25519' + +# Generate encrypted key pair +keyPair='$GEN:ed25519,enc' +``` + +### Custom Fields + +```bash +# Custom text field +c.text.Department="Engineering" + +# Custom multiline field +c.multiline.Notes="Line 1\nLine 2\nLine 3" + +# Custom secret field (masked) +c.secret.API_Key="secret-api-key-here" + +# Custom date field +c.date.Expiration="2024-12-31" +``` + +## Common Field Reference + +### Date Formats +```bash +# Unix timestamp +date=1668639533 + +# ISO format +date="2022-11-16T10:58:53Z" + +# Simple date +date="2022-11-16" +``` + +### Phone Number Format +```bash +phone.Work='$JSON:{"region": "US", "number": "(555) 555-1234", "ext": "123", "type": "Work"}' +phone.Mobile='$JSON:{"number": "(555) 555-1234", "type": "Mobile"}' +``` + +### Name Format +```bash +name='$JSON:{"first": "John", "middle": "Michael", "last": "Doe"}' +name='$JSON:{"first": "Jane", "last": "Smith"}' +``` + +### Address Format +```bash +address='$JSON:{"street1": "123 Main St", "street2": "Apt 4B", "city": "New York", "state": "NY", "zip": "10001", "country": "USA"}' +``` + +### Security Questions Format +```bash +securityQuestion.Mother='$JSON:[{"question": "What is your mother'\''s maiden name?", "answer": "Smith"}]' +securityQuestion.Pet='$JSON:[{"question": "What was your first pet'\''s name?", "answer": "Fluffy"}]' +``` + +## Self-Destructing Records (One-Time Shares) + +The `--self-destruct` option creates temporary records that automatically delete themselves after being accessed. This is perfect for sharing sensitive information that should only be viewed once. + +### How Self-Destruct Works + +1. **Creates a temporary shareable URL** that expires after your specified time +2. **Record stays in your vault** until someone opens the share URL +3. **Auto-deletes from your vault** 5 minutes after the URL is first accessed +4. **Maximum duration** is 6 months + +### Syntax + +```bash +--self-destruct [(m)inutes|(h)ours|(d)ays] +``` + +**Time Units:** +- `m` or `minutes` - Minutes (default if no unit specified) +- `h` or `hours` - Hours +- `d` or `days` - Days + +### Examples + +**Share temporary password (expires in 1 hour):** +```bash +record-add -t "Temporary Server Access" -rt login \ + login=admin \ + password='$GEN:rand,16' \ + url=https://server.company.com \ + --self-destruct 1h \ + --notes "Emergency access for John Doe" +``` + +**One-time WiFi credentials (expires in 30 minutes):** +```bash +record-add -t "Guest WiFi Access" -rt wifiCredentials \ + text.ssid="Company-Guest" \ + password=TempPass123 \ + --self-destruct 30m \ + --notes "Visitor access for meeting" +``` + +**Temporary file share (expires in 24 hours):** +```bash +record-add -t "Confidential Document" -rt file \ + file='@/path/to/sensitive-doc.pdf' \ + --self-destruct 1d \ + --notes "Contract for review - auto-deletes after viewing" +``` + +**Emergency contact info (expires in 2 hours):** +```bash +record-add -t "Emergency Contact" -rt contact \ + name='$JSON:{"first": "Emergency", "last": "Contact"}' \ + phone.Mobile='$JSON:{"number": "(555) 911-0000", "type": "Emergency"}' \ + --self-destruct 2h +``` + +### Return Value + +When using `--self-destruct`, the command returns a **shareable URL** instead of a record UID: + +```bash +$ record-add -t "Temp Password" -rt login login=user password=pass123 --self-destruct 1h +https://keepersecurity.com/vault/share/AbCdEf123456... +``` + +### Important Notes + +⚠️ **Security Considerations:** +- **URL is the key** - Anyone with the URL can access the record +- **No authentication required** - Share URLs bypass login requirements +- **One-time access** - Record deletes 5 minutes after first view +- **Cannot be recovered** - Once deleted, the record is gone forever + +⚠️ **Limitations:** +- **Maximum 6 months** expiration time +- **Cannot update** self-destructing records +- **No preview** - You can't see the record again after creation +- **Immediate sharing** - URL is active immediately upon creation + +### Best Practices + +1. **Copy the URL immediately** - You won't be able to retrieve it later +2. **Use short expiration times** for maximum security (minutes/hours vs days) +3. **Include context in notes** about why the record was created +4. **Share URL through secure channels** (encrypted messaging, in person) +5. **Generate strong passwords** using `$GEN` for temporary access +6. **Verify recipient received URL** before the expiration time + +### Use Cases + +- **Emergency access credentials** for system administrators +- **Temporary passwords** for contractors or consultants +- **One-time document sharing** for sensitive files +- **Guest network credentials** for visitors +- **Secure information handoffs** between team members +- **Time-sensitive shared secrets** for automated systems + +### Example Workflow + +```bash +# 1. Create self-destructing record +URL=$(record-add -t "Emergency DB Access" -rt databaseCredentials \ + host='$JSON:{"hostName": "db.company.com", "port": "5432"}' \ + login=emergency_user \ + password='$GEN:rand,20' \ + text.database="production" \ + --self-destruct 4h \ + --notes "Emergency access for incident response - $(date)") + +# 2. Share URL securely (example with secure messaging) +echo "Emergency database access: $URL" | secure-send user@company.com + +# 3. Record will auto-delete 5 minutes after first access +``` + +## Tips and Best Practices + +1. **Use single-line commands for copy-paste** to avoid trailing space issues +2. **Quote JSON values** to prevent shell interpretation +3. **Use $GEN for passwords** instead of hardcoding them +4. **Test with simple records first** before creating complex ones +5. **Use custom fields (c.) for non-standard data** +6. **Organize records in folders** using the `--folder` parameter +7. **Add meaningful notes** with `--notes` for context + +## Troubleshooting + +### Common Issues + +**"Expected: =, got: ; Missing `=`"** +- Remove trailing spaces after backslashes in multi-line commands +- Use single-line format for copy-paste + +**"Field type not supported"** +- Check available field types with `record-add --syntax-help` +- Use custom fields with `c.` prefix for non-standard fields + +**JSON parsing errors** +- Ensure JSON is properly quoted +- Escape single quotes in JSON: `'\''` +- Use double quotes inside JSON objects + +**File attachment errors** +- Use `@` prefix: `file=@/path/to/file.txt` +- Ensure file path is accessible +- Use absolute paths to avoid confusion + +## Record-Update vs Record-Add + +While `record-add` creates new records, `record-update` modifies existing records. Here's how they compare: + +### Key Differences + +| Feature | record-add | record-update | +|---------|------------|---------------| +| Purpose | Creates new records | Modifies existing records | +| Record identifier | Not required | **Required** (`-r` or `--record`) | +| Record type | Required (`-rt`) | Optional (can change type) | +| Field behavior | Sets all fields | Updates only specified fields | +| Notes behavior | Sets notes | Appends with `+` prefix, overwrites without | + +### Record-Update Syntax + +```bash +record-update --record "RECORD_TITLE_OR_UID" [OPTIONS] [FIELDS...] +``` + +**Key Arguments:** +- `--record` / `-r`: Record title or UID (required) +- `--title` / `-t`: Update record title +- `--record-type` / `-rt`: Change record type +- `--notes` / `-n`: Update notes (`+text` appends, `text` overwrites) +- `--force` / `-f`: Ignore warnings + +### Examples + +**Update password and URL:** +```bash +record-update -r "Gmail Account" \ + password='$GEN:rand,20' \ + url=https://accounts.google.com/new-login +``` + +**Add a phone number to existing contact:** +```bash +record-update -r "John Smith" \ + phone.Work='$JSON:{"number": "(555) 987-6543", "type": "Work"}' +``` + +**Append to notes (notice the + prefix):** +```bash +record-update -r "Server Credentials" \ + --notes "+Updated password on 2024-01-15" +``` + +**Update title and add custom field:** +```bash +record-update -r "Old Server Name" \ + --title "Production Web Server" \ + c.text.Environment="Production" \ + c.text.Last_Updated="2024-01-15" +``` + +**Change record type (converts structure):** +```bash +record-update -r "Simple Login" \ + --record-type contact \ + name='$JSON:{"first": "John", "last": "Doe"}' \ + email=john.doe@example.com +``` + +### When to Use Each Command + +**Use `record-add` when:** +- Creating a completely new record +- You want to specify all fields from scratch +- Setting up initial record structure + +**Use `record-update` when:** +- Modifying existing records +- Adding new fields to existing records +- Updating passwords or other credentials +- Appending information to notes +- Converting between record types + +**Important Notes:** +- `record-update` only changes the fields you specify +- Existing fields not mentioned remain unchanged +- Use `field=` (empty value) to clear a field +- Notes with `+` prefix append, without `+` they replace + +## Getting Help + +```bash +# View all available record types +record-type-info + +# View fields for a specific record type +record-type-info --list-record login + +# View field information +record-type-info --list-field phone + +# View field syntax help +record-add --syntax-help + +# View record-update syntax help +record-update --help +``` \ No newline at end of file diff --git a/keepercommander/cli.py b/keepercommander/cli.py index d5a2f3c8d..68dd3608a 100644 --- a/keepercommander/cli.py +++ b/keepercommander/cli.py @@ -341,6 +341,43 @@ def force_quit(): prompt_session = None +def read_command_with_continuation(prompt_session, params): + """Read command with support for line continuation using backslash.""" + command_lines = [] + continuation_prompt = "... " + current_prompt = get_prompt(params) + + while True: + if prompt_session is not None: + line = prompt_session.prompt(current_prompt) + else: + line = input(current_prompt) + + # Check if line ends with backslash (line continuation) + # First strip all trailing whitespace, then check for backslash + stripped_line = line.rstrip() + if stripped_line.endswith('\\'): + # Remove the backslash and any remaining whitespace + line_content = stripped_line[:-1].strip() + if line_content: # Only add non-empty lines + command_lines.append(line_content) + current_prompt = continuation_prompt + else: + # No continuation, add the final line if it has content + line_content = stripped_line + if line_content: + command_lines.append(line_content) + break + + # Join all lines with spaces, ensuring no extra spaces + # Also clean up any multiple spaces that might have been introduced + result = ' '.join(command_lines) + # Replace multiple spaces with single spaces to handle any remaining formatting issues + import re + result = re.sub(r'\s+', ' ', result).strip() + return result + + def loop(params): # type: (KeeperParams) -> int global prompt_session error_no = 0 @@ -401,9 +438,9 @@ def loop(params): # type: (KeeperParams) -> int enforcement_checked.add(params.user) do_command(params, 'check-enforcements') - command = prompt_session.prompt(get_prompt(params)) + command = read_command_with_continuation(prompt_session, params) else: - command = input(get_prompt(params)) + command = read_command_with_continuation(None, params) if tmer: tmer.cancel() tmer = None diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index 19b576d38..7c75f76a6 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -720,6 +720,8 @@ def execute(self, params, **kwargs): raise CommandError('record-add', 'Record type parameter is required.') fields = kwargs.get('fields', []) + # Filter out empty strings that might be introduced by copy-paste or line continuation issues + fields = [field.strip() for field in fields if field.strip()] record_fields = [] # type: List[ParsedFieldValue] add_attachments = [] # type: List[ParsedFieldValue] @@ -841,6 +843,8 @@ def execute(self, params, **kwargs): record.notes = notes fields = kwargs.get('fields', []) + # Filter out empty strings that might be introduced by copy-paste or line continuation issues + fields = [field.strip() for field in fields if field.strip()] record_fields = [] # type: List[ParsedFieldValue] add_attachments = [] # type: List[ParsedFieldValue] diff --git a/unit-tests/test_cli.py b/unit-tests/test_cli.py index ad879630f..8db9d93dc 100644 --- a/unit-tests/test_cli.py +++ b/unit-tests/test_cli.py @@ -3,7 +3,7 @@ from unittest import TestCase, mock from keepercommander.commands import base -from keepercommander.cli import do_command +from keepercommander.cli import do_command, read_command_with_continuation from data_vault import get_connected_params @@ -49,3 +49,63 @@ def test_do_command_no_opts(self): mock_print_dev.return_value = 'test device info' do_command(params, 'this-device') mock_print_dev.assert_called() + + def test_line_continuation(self): + """Test that line continuation with backslash works correctly.""" + params = get_connected_params() + + # Mock input to simulate line continuation with varying whitespace + input_lines = [ + 'record-add -t "Test Record" \\', + ' -rt login \\', # Leading whitespace + ' login=testuser \\', # Leading whitespace + ' password=testpass' # Final line with leading whitespace + ] + + with mock.patch('builtins.input', side_effect=input_lines): + result = read_command_with_continuation(None, params) + expected = 'record-add -t "Test Record" -rt login login=testuser password=testpass' + self.assertEqual(result, expected) + + def test_line_continuation_no_backslash(self): + """Test that commands without line continuation work normally.""" + params = get_connected_params() + + with mock.patch('builtins.input', return_value='record-add -t "Simple Test" -rt login'): + result = read_command_with_continuation(None, params) + expected = 'record-add -t "Simple Test" -rt login' + self.assertEqual(result, expected) + + def test_line_continuation_with_empty_lines(self): + """Test that empty continuation lines are handled correctly.""" + params = get_connected_params() + + # Mock input with empty continuation lines + input_lines = [ + 'record-add -t "Test Record" \\', + ' \\', # Empty line with just backslash - should be skipped + ' -rt login \\', + 'login=testuser' # Final line without backslash + ] + + with mock.patch('builtins.input', side_effect=input_lines): + result = read_command_with_continuation(None, params) + expected = 'record-add -t "Test Record" -rt login login=testuser' + self.assertEqual(result, expected) + + def test_line_continuation_with_trailing_spaces(self): + """Test that line continuation handles trailing spaces after backslash gracefully.""" + params = get_connected_params() + + # Mock input with trailing spaces after backslashes (common user error) + input_lines = [ + 'record-add -t "Gmail Account" -rt login \\ ', # space after backslash + ' login=john.doe@gmail.com \\ ', # spaces after backslash + ' password=SecurePass123 \\ ', # tab after backslash + ' url=https://accounts.google.com' + ] + + with mock.patch('builtins.input', side_effect=input_lines): + result = read_command_with_continuation(None, params) + expected = 'record-add -t "Gmail Account" -rt login login=john.doe@gmail.com password=SecurePass123 url=https://accounts.google.com' + self.assertEqual(result, expected)