Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
642 changes: 642 additions & 0 deletions RECORD_ADD_DOCUMENTATION.md

Large diffs are not rendered by default.

82 changes: 81 additions & 1 deletion keepercommander/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import re
import shlex
import sys
import ssl
import platform

from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -129,14 +131,86 @@ 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 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
]
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:
sys.stdout.reconfigure(encoding='utf-8')
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
else:
# User explicitly disabled SSL verification
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')

errno = 0
Expand All @@ -159,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

Expand Down
41 changes: 39 additions & 2 deletions keepercommander/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions keepercommander/commands/pam_saas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -79,10 +80,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
Expand All @@ -92,7 +93,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]]:
Expand Down Expand Up @@ -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://github.com/@api/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}")
Expand All @@ -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}")
Expand Down
25 changes: 15 additions & 10 deletions keepercommander/commands/pam_saas/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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 = []
Expand Down Expand Up @@ -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)
Expand All @@ -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])

Expand Down Expand Up @@ -331,8 +336,8 @@ 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":
res = requests.get(plugin.file)
if plugin.type == "catalog" and 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}")
Expand Down
34 changes: 26 additions & 8 deletions keepercommander/commands/pam_saas/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,17 +55,24 @@ 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)
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

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)
Expand Down Expand Up @@ -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.
Expand All @@ -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")

Expand Down
4 changes: 4 additions & 0 deletions keepercommander/commands/record_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Loading