Skip to content
Merged

Release #2086

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
2 changes: 1 addition & 1 deletion keepercommander/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@
# Contact: commander@keepersecurity.com
#

__version__ = '18.0.3'
__version__ = '18.0.4'
48 changes: 40 additions & 8 deletions keepercommander/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import os
import re
import shlex
import shutil
import subprocess
import sys
import threading
Expand Down Expand Up @@ -248,7 +249,15 @@ def command_and_args_from_cmd(command_line):
return cmd, args


_DISALLOWED_COMMAND_CHARS = ('\r', '\n', '\x00')


def do_command(params, command_line):
# Block control characters to prevent newline-injection at the shell boundary.
if isinstance(command_line, str) and any(ch in command_line for ch in _DISALLOWED_COMMAND_CHARS):
logging.error('Invalid command: control characters are not allowed in command input.')
return

def is_msp(params_local):
if params_local.enterprise:
if 'licenses' in params_local.enterprise:
Expand Down Expand Up @@ -301,20 +310,43 @@ def is_msp(params_local):
return

if command_line.startswith('ksm'):
try:
subprocess.check_call([OS_WHICH_CMD, 'ksm'], stdout=subprocess.DEVNULL)
except subprocess.CalledProcessError:
# Exclude cwd from PATH on Windows so a local `ksm.bat` can't hijack
# the `ksm` prefix via CreateProcess's implicit cwd search.
search_path = os.environ.get('PATH', os.defpath)
if sys.platform.startswith('win'):
try:
cwd_abs = os.path.abspath(os.getcwd())
except OSError:
cwd_abs = None
if cwd_abs:
search_path = os.pathsep.join(
p for p in search_path.split(os.pathsep)
if p and os.path.abspath(p) != cwd_abs
)

ksm_path = shutil.which('ksm', path=search_path)
if not ksm_path:
logging.error(
'Please install the ksm application to run ksm commands.\n'
'See https://docs.keeper.io/secrets-manager/secrets-manager'
'/secrets-manager-command-line-interface'
'#secrets-manager-cli-installation'
)
else:
if sys.platform.startswith('win'):
subprocess.check_call(command_line)
else:
subprocess.check_call(shlex.split(command_line))
return

try:
ksm_args = shlex.split(command_line, posix=not sys.platform.startswith('win'))
except ValueError as e:
logging.error('Invalid ksm command: %s', e)
return

if not ksm_args or ksm_args[0].lower() != 'ksm':
logging.error('Invalid ksm command.')
return

# Invoke via argv list with shell=False so arguments aren't re-parsed.
ksm_args[0] = ksm_path
subprocess.check_call(ksm_args, shell=False)
return
elif '-h' in command_line.lower():
if command_line.lower().startswith('h ') or command_line.lower().startswith('history '):
Expand Down
6 changes: 6 additions & 0 deletions keepercommander/commands/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ def register_command_info(aliases, command_info):


list_sf_parser = argparse.ArgumentParser(prog='list-sf', description='List all shared folders', parents=[base.report_output_parser])
list_sf_parser.add_argument('--roe-eligible', dest='roe_eligible', action='store_true',
help='only list shared folders eligible for --rotate-on-expiration '
'(contain at least one pamUser record with rotation configured)')
list_sf_parser.add_argument('pattern', nargs='?', type=str, action='store', help='search pattern')


Expand Down Expand Up @@ -1766,6 +1769,9 @@ def execute(self, params, **kwargs):
fmt = kwargs.get('format', 'table')
pattern = kwargs['pattern'] if 'pattern' in kwargs else None
results = api.search_shared_folders(params, pattern or '')
if kwargs.get('roe_eligible'):
results = [sf for sf in results
if vault_extensions.shared_folder_has_pam_user_with_rotation(params, sf.shared_folder_uid)]
if any(results):
table = []
headers = ['shared_folder_uid', 'name'] if fmt == 'json' else ['Shared Folder UID', 'Name']
Expand Down
86 changes: 56 additions & 30 deletions keepercommander/commands/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from .helpers.timeout import parse_timeout
from .record import RecordRemoveCommand
from .utils import SyncDownCommand
from .. import api, utils, crypto, constants, rest_api, vault
from .. import api, utils, crypto, constants, rest_api, vault, vault_extensions
from ..display import bcolors
from ..error import KeeperApiError, CommandError, Error
from ..loginv3 import LoginV3API
Expand Down Expand Up @@ -89,8 +89,8 @@ def register_command_info(aliases, command_info):
help='share expiration: never or period')
share_record_parser.add_argument('-roe', '--rotate-on-expiration', dest='rotate_on_expiration', action='store_true',
help='rotate the password when the share access expires. '
'Requires --action=grant, a positive --expire-at/--expire-in '
'(not "never"), and a pamUser record.')
'Only valid on grant; requires a positive --expire-at/--expire-in '
'(not "never") and a pamUser record with rotation configured.')
share_record_parser.add_argument('record', nargs='?', type=str, action='store', help='record/shared folder path/UID')

share_folder_parser = argparse.ArgumentParser(prog='share-folder', description='Change the permissions of a shared folder')
Expand Down Expand Up @@ -118,6 +118,11 @@ def register_command_info(aliases, command_info):
help='share expiration: never or ISO datetime (yyyy-MM-dd[ hh:mm:ss])')
expiration.add_argument('--expire-in', dest='expire_in', action='store', metavar='PERIOD',
help='share expiration: never or period (<NUMBER>[(y)ears|(mo)nths|(d)ays|(h)ours(mi)nutes]')
share_folder_parser.add_argument('-roe', '--rotate-on-expiration', dest='rotate_on_expiration', action='store_true',
help='rotate the password when the share access expires. '
'Only valid on grant; requires a positive --expire-at/--expire-in '
'(not "never") and at least one pamUser record with rotation '
'configured in the folder.')
share_folder_parser.add_argument('folder', nargs='+', type=str, action='store', help='shared folder path or UID')

share_report_parser = argparse.ArgumentParser(prog='share-report', description='Generates a report of shared records',
Expand Down Expand Up @@ -307,6 +312,25 @@ def get_share_admin_obj_uids(obj_names, obj_type):
if action == 'grant':
share_expiration = get_share_expiration(kwargs.get('expire_at'), kwargs.get('expire_in'))

rotate_on_expiration = bool(kwargs.get('rotate_on_expiration'))
if rotate_on_expiration:
if action != 'grant':
raise CommandError('share-folder',
'--rotate-on-expiration is only valid when granting access')
if not isinstance(share_expiration, int) or share_expiration <= 0:
raise CommandError('share-folder',
'--rotate-on-expiration requires a positive --expire-at / --expire-in '
'(cannot be "never").')
ineligible_folders = [
uid for uid in shared_folder_uids
if not vault_extensions.shared_folder_has_pam_user_with_rotation(params, uid)
]
if ineligible_folders:
raise CommandError(
'share-folder',
'--rotate-on-expiration requires the folder to contain at least one pamUser record '
'with rotation configured. Ineligible folder(s): ' + ', '.join(sorted(ineligible_folders)))

as_users = set()
as_teams = set()

Expand Down Expand Up @@ -368,7 +392,8 @@ def get_share_admin_obj_uids(obj_names, obj_type):

def prep_rq(recs, users, curr_sf):
return self.prepare_request(params, kwargs, curr_sf, users, sf_teams, recs, default_record=default_record,
default_account=default_account, share_expiration=share_expiration)
default_account=default_account, share_expiration=share_expiration,
rotate_on_expiration=rotate_on_expiration)

for sf_uid in shared_folder_uids:
sf_users = as_users.copy()
Expand Down Expand Up @@ -419,7 +444,7 @@ def prep_rq(recs, users, curr_sf):
@staticmethod
def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *,
default_record=False, default_account=False,
share_expiration=None):
share_expiration=None, rotate_on_expiration=False):
rq = folder_pb2.SharedFolderUpdateV3Request()
rq.sharedFolderUid = utils.base64_url_decode(curr_sf['shared_folder_uid'])
if 'revision' in curr_sf:
Expand All @@ -429,6 +454,19 @@ def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *,
action = kwargs.get('action') or 'grant'
mr = kwargs.get('manage_records')
mu = kwargs.get('manage_users')

def apply_share_expiration(target):
"""Set expiration / timer / rotateOnExpiration on a User/Team/Record update proto."""
if not isinstance(share_expiration, int):
return
if share_expiration > 0:
target.expiration = share_expiration * 1000
target.timerNotificationType = record_pb2.NOTIFY_OWNER
if rotate_on_expiration:
target.rotateOnExpiration = True
elif share_expiration < 0:
target.expiration = -1

if default_account and action == 'grant':
if mr is not None:
rq.defaultManageRecords = folder_pb2.BOOLEAN_TRUE if mr == 'on' else folder_pb2.BOOLEAN_FALSE
Expand All @@ -444,12 +482,7 @@ def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *,
for email in users:
uo = folder_pb2.SharedFolderUpdateUser()
uo.username = email
if isinstance(share_expiration, int):
if share_expiration > 0:
uo.expiration = share_expiration * 1000
uo.timerNotificationType = record_pb2.NOTIFY_OWNER
elif share_expiration < 0:
uo.expiration = -1
apply_share_expiration(uo)
if email in existing_users:
if action == 'grant':
uo.manageRecords = folder_pb2.BOOLEAN_NO_CHANGE if mr is None else folder_pb2.BOOLEAN_TRUE if mr == 'on' else folder_pb2.BOOLEAN_FALSE
Expand Down Expand Up @@ -489,12 +522,7 @@ def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *,
for team_uid in teams:
to = folder_pb2.SharedFolderUpdateTeam()
to.teamUid = utils.base64_url_decode(team_uid)
if isinstance(share_expiration, int):
if share_expiration > 0:
to.expiration = share_expiration * 1000
to.timerNotificationType = record_pb2.NOTIFY_OWNER
elif share_expiration < 0:
to.expiration = -1
apply_share_expiration(to)
if team_uid in existing_teams:
team = existing_teams[team_uid]
if action == 'grant':
Expand Down Expand Up @@ -546,12 +574,7 @@ def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *,
for record_uid in rec_uids:
ro = folder_pb2.SharedFolderUpdateRecord()
ro.recordUid = utils.base64_url_decode(record_uid)
if isinstance(share_expiration, int):
if share_expiration > 0:
ro.expiration = share_expiration * 1000
ro.timerNotificationType = record_pb2.NOTIFY_OWNER
elif share_expiration < 0:
ro.expiration = -1
apply_share_expiration(ro)

if record_uid in existing_records:
if action == 'grant':
Expand All @@ -562,8 +585,10 @@ def prepare_request(params, kwargs, curr_sf, users, teams, rec_uids, *,
rq.sharedFolderRemoveRecord.append(ro.recordUid)
else:
if action == 'grant':
ro.canEdit = curr_sf.get('default_can_edit') is True if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE
ro.canShare = curr_sf.get('default_can_share') is True if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE
default_ce = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_can_edit') is True else folder_pb2.BOOLEAN_FALSE
default_cs = folder_pb2.BOOLEAN_TRUE if curr_sf.get('default_can_share') is True else folder_pb2.BOOLEAN_FALSE
ro.canEdit = default_ce if ce is None else folder_pb2.BOOLEAN_TRUE if ce == 'on' else folder_pb2.BOOLEAN_FALSE
ro.canShare = default_cs if cs is None else folder_pb2.BOOLEAN_TRUE if cs == 'on' else folder_pb2.BOOLEAN_FALSE
sf_key = curr_sf.get('shared_folder_key_unencrypted')
if sf_key:
rec = params.record_cache[record_uid]
Expand Down Expand Up @@ -671,7 +696,7 @@ def prep_request(params, kwargs): # type: (KeeperParams, Dict[str, Any]) -> Un
if rotate_on_expiration:
if action != 'grant':
raise CommandError('share-record',
'--rotate-on-expiration is only valid with --action=grant')
'--rotate-on-expiration is only valid when granting access')
if not isinstance(share_expiration, int) or share_expiration <= 0:
raise CommandError('share-record',
'--rotate-on-expiration requires a positive --expire-at / --expire-in '
Expand Down Expand Up @@ -763,15 +788,16 @@ def prep_request(params, kwargs): # type: (KeeperParams, Dict[str, Any]) -> Un
raise CommandError('share-record', 'There are no records to share selected')

if rotate_on_expiration:
non_pam_user_uids = [
ineligible_record_uids = [
ruid for ruid in record_uids
if getattr(vault.KeeperRecord.load(params, ruid), 'record_type', None) != 'pamUser'
or not vault_extensions.record_has_rotation_configured(params, ruid)
]
if non_pam_user_uids:
if ineligible_record_uids:
raise CommandError(
'share-record',
'--rotate-on-expiration is only supported for pamUser records. '
'Not pamUser: ' + ', '.join(sorted(non_pam_user_uids)))
'--rotate-on-expiration requires a pamUser record with rotation configured. '
'Ineligible record(s): ' + ', '.join(sorted(ineligible_record_uids)))

if action == 'owner' and len(emails) > 1:
raise CommandError('share-record', 'You can transfer ownership to a single account only')
Expand Down
16 changes: 12 additions & 4 deletions keepercommander/commands/supershell/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,20 +586,26 @@ def _load_vault_data(self):
record_dict['password'] = field_value[0]
elif isinstance(field_value, str):
record_dict['password'] = field_value
if field_label:
record_dict['password_label'] = field_label

# Extract login from typed field if not already set
if field_type == 'login' and field_value and not record_dict.get('login'):
if isinstance(field_value, list) and len(field_value) > 0:
record_dict['login'] = field_value[0]
elif isinstance(field_value, str):
record_dict['login'] = field_value
if field_label:
record_dict['login_label'] = field_label

# Extract URL from typed field if not already set
if field_type == 'url' and field_value and not record_dict.get('login_url'):
if isinstance(field_value, list) and len(field_value) > 0:
record_dict['login_url'] = field_value[0]
elif isinstance(field_value, str):
record_dict['login_url'] = field_value
if field_label:
record_dict['login_url_label'] = field_label

# Extract TOTP URL from oneTimeCode field
if field_type == 'oneTimeCode' and field_value and not record_dict.get('totp_url'):
Expand Down Expand Up @@ -2077,17 +2083,19 @@ def display_launch_section():
# Show 'app' for app records if type is blank
display_type = value if value else 'app' if record_uid in self.app_record_uids else ''
mount_line(f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [{t['primary_dim']}]{rich_escape(str(display_type))}[/{t['primary_dim']}]", display_type)
elif key == 'Password':
elif key == 'Password' or (record_data.get('password_label') and key == record_data['password_label']):
# Show masked password but use ClipboardCommand to copy (generates audit event)
# Respect unmask_secrets toggle
# Respect unmask_secrets toggle. Matches both the canonical "Password" label
# and any custom label (e.g. "Passphrase") set on a type=password field.
if self.unmask_secrets:
display_value = actual_password if actual_password else value
else:
display_value = '******' if actual_password else value
copy_value = actual_password if actual_password else None
mount_line(f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(display_value))}[/{t['primary']}]", copy_value, is_password=True)
elif key == 'URL':
# Display URL, then TOTP if present
elif key == 'URL' or (record_data.get('login_url_label') and key == record_data['login_url_label']):
# Display URL, then TOTP if present. Matches both the canonical "URL" label
# and any custom label set on a type=url field.
mount_line(f"[{t['text_dim']}]{key}:[/{t['text_dim']}] [{t['primary']}]{rich_escape(str(value))}[/{t['primary']}]", value)
display_totp() # Add TOTP section right after URL (before Notes)
elif key == 'Notes':
Expand Down
Loading
Loading