Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ __pycache__/
.mcp/
tests/*_results/
tests/*.log
keepercommander/service/core/logs/
tests/compliance_test.env
docker-compose.yml
uv.lock
1 change: 1 addition & 0 deletions docs/cyberark-pam-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ pam project cyberark-cleanup --name "CyberArk Migration" --dry-run
| `--user-map` | CyberArk→Keeper user mapping JSON file |
| `--no-verify-ssl` | Disable SSL verification (self-hosted only) |
| `--include-system-safes` | Include system safes (PSM, PasswordManager, etc.) |
| `--add-cyberark-users` | Create CybeArk Users in Keeper Admin Console. |

---

Expand Down
2 changes: 1 addition & 1 deletion keepercommander/command_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
'this-device', 'login', 'login-status', 'biometric', 'whoami', 'logout',
'help', 'sync-down', 'version', 'clear', 'history', 'quit', 'run-batch', 'generate',
'reset-password', 'sync-security-data', 'keeper-fill', '2fa', 'create-account',
'run-as', 'sleep', 'server', 'proxy', 'keep-alive', 'supershell'
'run-as', 'sleep', 'server', 'proxy', 'keep-alive', 'supershell', 'notification'
},

# KeeperPAM Commands
Expand Down
85 changes: 60 additions & 25 deletions keepercommander/commands/aram.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from .. import api, vault, record_management
from ..constants import EMAIL_PATTERN
from ..display import bcolors
from ..error import CommandError
from ..error import CommandError, Error
from ..params import KeeperParams
from ..proto import enterprise_pb2
from ..sox import sox_data, get_prelim_data, is_compliance_reporting_enabled, get_sox_database_name, \
Expand Down Expand Up @@ -177,8 +177,25 @@
API_EVENT_RAW_ROW_LIMIT = 1000


def fetch_audit_events(params, audit_filter, columns=None, aggregate=None, report_type='span', limit=None, order=None):
# type: (KeeperParams, Dict[str, Any], Optional[List[str]], Optional[List[str]], str, Optional[int], Optional[str]) -> List[Dict[str, Any]]
def raise_if_audit_report_failed(response, cmd):
# type: (dict, str) -> None
if isinstance(response, dict) and response.get('result') == 'fail':
raise CommandError(cmd, response.get('message') or 'Request failed')


def communicate_audit_report(params, request, cmd):
# type: (KeeperParams, Dict[str, Any], str) -> Dict[str, Any]
try:
response = api.communicate(params, request)
except Error as e:
raise CommandError(cmd, e.message) from e
raise_if_audit_report_failed(response, cmd)
return response


def fetch_audit_events(params, audit_filter, columns=None, aggregate=None, report_type='span', limit=None, order=None,
cmd='audit-report'):
# type: (KeeperParams, Dict[str, Any], Optional[List[str]], Optional[List[str]], str, Optional[int], Optional[str], str) -> List[Dict[str, Any]]
"""
Fetch audit events with pagination support.

Expand Down Expand Up @@ -235,7 +252,8 @@ def fetch_audit_events(params, audit_filter, columns=None, aggregate=None, repor
should_stop = False

for idx, rs in enumerate(rss):
batch_events = rs.get('audit_event_overview_report_rows', [])
raise_if_audit_report_failed(rs, cmd)
batch_events = rs.get('audit_event_overview_report_rows') or []
if not batch_events or not isinstance(batch_events, list):
continue

Expand Down Expand Up @@ -283,8 +301,8 @@ def fetch_audit_events(params, audit_filter, columns=None, aggregate=None, repor
reverse = 'descending' if asc else 'ascending'
last_rq['order'] = reverse
last_rq['limit'] = 1
rs_last = api.communicate(params, last_rq)
last_row_events = rs_last.get('audit_event_overview_report_rows')
rs_last = communicate_audit_report(params, last_rq, cmd)
last_row_events = rs_last.get('audit_event_overview_report_rows') or []
if last_row_events:
last_row = last_row_events[0]
last_timestamp = last_row.get(timestamp_field, last_row.get('created', 0))
Expand Down Expand Up @@ -583,9 +601,12 @@ def __init__(self):
def convert_event(self, props, event):
pri = 13 * 8 + 6
dt = datetime.datetime.fromtimestamp(event['created'], tz=datetime.timezone.utc)
ip = "-"
if 'ip_address' in event:
ip = event['ip_address']
# RFC 5424 section 6: HOSTNAME = NILVALUE / 1*255PRINTUSASCII (codes 33-126).
# Fall back to NILVALUE "-" for missing, null, empty, whitespace or non-printable values.
ip = str(event.get('ip_address') or '').strip()
if not ip or not all(33 <= ord(ch) <= 126 for ch in ip):
ip = '-'
ip = ip[:255]

message = '<{0}>1 {1} {2} {3} - {4}'.format(pri, dt.strftime('%Y-%m-%dT%H:%M:%SZ'), ip, 'Keeper', event['id'])

Expand Down Expand Up @@ -927,9 +948,20 @@ def resolve_uid(self, cache, username):
uid = cache.get(uname)
return uid

def execute(self, params, **kwargs):
load_syslog_templates(params)
@staticmethod
def validate_reporting_access(params):
"""Ask the server to authorize audit reporting before prompting or creating records."""
now_ts = int(datetime.datetime.now().timestamp())
request = {
'command': 'get_audit_event_reports',
'report_type': 'span',
'scope': 'enterprise',
'limit': 1,
'filter': {'created': {'min': 0, 'max': now_ts}}
}
communicate_audit_report(params, request, 'audit-log')

def execute(self, params, **kwargs):
target = kwargs.get('target')

log_export = None # type: Optional[AuditLogBaseExport]
Expand All @@ -948,6 +980,9 @@ def execute(self, params, **kwargs):
else:
raise CommandError('audit-log', 'Audit log export: unsupported target')

self.validate_reporting_access(params)
load_syslog_templates(params)

self.log_export = log_export
record = None # type: Union[vault.PasswordRecord, vault.TypedRecord, None]
record_name = kwargs.get('record') or log_export.default_record_title()
Expand Down Expand Up @@ -1061,24 +1096,20 @@ def execute(self, params, **kwargs):
filter_copy = {**rq_filter, 'created': created_filter_copy}
total_events_rq = {**rq, 'filter': filter_copy, 'report_type': 'span'}
total_events = 0
try:
total_events_rs = api.communicate(params, total_events_rq)
rows = total_events_rs['audit_event_overview_report_rows']
total_events = rows[0].get('occurrences', 0) if rows else 0
except:
logging.info('No events to export')
return
total_events_rs = communicate_audit_report(params, total_events_rq, 'audit-log')
rows = total_events_rs.get('audit_event_overview_report_rows') or []
total_events = rows[0].get('occurrences', 0) if rows else 0

while not finished:
finished = True

if last_event_time > 0:
created_filter['min'] = last_event_time

rs = api.communicate(params, rq)
if rs['result'] == 'success':
rs = communicate_audit_report(params, rq, 'audit-log')
if rs.get('result') == 'success':
finished = True
if 'audit_event_overview_report_rows' in rs:
if rs.get('audit_event_overview_report_rows'):
audit_events = rs['audit_event_overview_report_rows']
event_count = len(audit_events)
last_event_time = int(audit_events[-1]['created']) if event_count else now_ts
Expand Down Expand Up @@ -1750,7 +1781,8 @@ def normalize_param(param_value):
while reqs:
reqs = []
for rs in rss:
events = rs.get('audit_event_overview_report_rows')
raise_if_audit_report_failed(rs, 'audit-report')
events = rs.get('audit_event_overview_report_rows') or []
for event in events:
if misc_fields:
lenf = len(fields)
Expand Down Expand Up @@ -1823,7 +1855,8 @@ def normalize_param(param_value):
aggregate=req.get('aggregate'),
report_type=report_type,
limit=user_limit,
order=req.get('order')
order=req.get('order'),
cmd='audit-report'
)
all_events.extend(events)

Expand Down Expand Up @@ -2165,7 +2198,8 @@ def get_excluded(candidate_usernames, query_filter, username_field='username'):
columns=cols,
aggregate=['last_created'],
report_type='span',
limit=None # Get all matching events
limit=None, # Get all matching events
cmd='action-report'
)

# Extract usernames from events that match the candidate usernames
Expand Down Expand Up @@ -2218,7 +2252,8 @@ def get_latest_lock_times(usernames):
columns=['to_username'],
aggregate=['last_created'],
report_type='span',
limit=API_EVENT_SUMMARY_ROW_LIMIT
limit=API_EVENT_SUMMARY_ROW_LIMIT,
cmd='action-report'
)
for event in events:
username = (event.get('to_username') or '').lower()
Expand Down
19 changes: 15 additions & 4 deletions keepercommander/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ def register_commands(commands, aliases, command_info):
device_management.register_commands(commands)
device_management.register_command_info(aliases, command_info)

from . import notification
notification.register_commands(commands)
notification.register_command_info(aliases, command_info)

from . import nested_share_folder
nested_share_folder.register_commands(commands)
nested_share_folder.register_command_info(aliases, command_info)
Expand Down Expand Up @@ -709,6 +713,14 @@ def normalize_output_param(args: str) -> str:
return args


def tokenize_command_args(args, envvars=None):
"""Tokenize command arguments using the same transforms as Command.execute_args."""
args = '' if args is None else args
if envvars is not None:
args = expand_cmd_args(args, envvars)
return shlex.split(normalize_output_param(args))


def as_boolean(value, default=None): # type: (Any, Optional[bool]) -> bool
if isinstance(value, bool):
return value
Expand Down Expand Up @@ -765,14 +777,13 @@ def execute_args(self, params, args, **kwargs):
alias_cmd = d.get('command')
if alias_cmd and alias_cmd != parser.prog:
parser.prog = alias_cmd
args = expand_cmd_args(args, envvars)
args = normalize_output_param(args)
command_tokens = tokenize_command_args(args, envvars)
if self.support_extra_parameters():
opts, extra_args = parser.parse_known_args(shlex.split(args))
opts, extra_args = parser.parse_known_args(command_tokens)
if extra_args:
self.extra_parameters = ' '.join(extra_args)
else:
opts = parser.parse_args(shlex.split(args))
opts = parser.parse_args(command_tokens)
d.update(opts.__dict__)

return self.execute(params, **d)
Expand Down
14 changes: 10 additions & 4 deletions keepercommander/commands/compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from ..sox import sox_types, get_node_id
from ..sox.sox_data import SoxData
from ..sox.storage_types import StorageRecordAging
from .aram import API_EVENT_SUMMARY_ROW_LIMIT
from .aram import API_EVENT_SUMMARY_ROW_LIMIT, raise_if_audit_report_failed

compliance_parser = argparse.ArgumentParser(add_help=False, parents=[report_output_parser])
rebuild_group = compliance_parser.add_mutually_exclusive_group()
Expand Down Expand Up @@ -228,8 +228,10 @@ def get_requests(filter_recs, filter_type, order='descending', aggregate='last_c
for stat in aging_stats:
start, count = request_slices[stat]
stat_responses = responses[start:start + count]
for response in stat_responses:
raise_if_audit_report_failed(response, 'compliance report')
events = list(itertools.chain.from_iterable(
rs.get('audit_event_overview_report_rows', []) for rs in stat_responses
(rs.get('audit_event_overview_report_rows') or []) for rs in stat_responses
))
aggregate = 'first_created' if stat == 'created' else 'last_created'
record_timestamps = {e.get('record_uid'): e.get(aggregate) for e in events if e.get('record_uid')}
Expand All @@ -238,8 +240,10 @@ def get_requests(filter_recs, filter_type, order='descending', aggregate='last_c
# Parse event 80 occurrence counts
pw_count_start, pw_count_n = request_slices['_pw_count']
pw_count_responses = responses[pw_count_start:pw_count_start + pw_count_n]
for response in pw_count_responses:
raise_if_audit_report_failed(response, 'compliance report')
pw_count_events = list(itertools.chain.from_iterable(
rs.get('audit_event_overview_report_rows', []) for rs in pw_count_responses
(rs.get('audit_event_overview_report_rows') or []) for rs in pw_count_responses
))
pw_occurrences = {e.get('record_uid'): int(e.get('occurrences', 0))
for e in pw_count_events if e.get('record_uid')}
Expand Down Expand Up @@ -824,9 +828,11 @@ def get_records_accessed(emails, limit_to_vault=False):
spinner.message = f'Loading record access events - Users remaining: {len(users_to_query)}/{total_users}'
requests = [get_records_accessed_rq(email, **filters_by_user.get(email)) for email in users_to_query]
responses = api.execute_batch(params, requests)
for response in responses:
raise_if_audit_report_failed(response, 'compliance record-access-report')
responses_by_user = zip(users_to_query, responses)
for user, response in responses_by_user:
access_events = response.get('audit_event_overview_report_rows', [])
access_events = response.get('audit_event_overview_report_rows') or []
records_accessed = records_accessed_by_user.get(user, {})
records_accessed_new, filters = process_access_events(access_events, filter_recs=filters_by_user.get(user, {}).get('filter_recs'))
for rec_uid, event in records_accessed_new.items():
Expand Down
17 changes: 9 additions & 8 deletions keepercommander/commands/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -4014,7 +4014,8 @@ def get_user_info(user):
report_filter['username'] = missing[:limit]
missing = missing[limit:]
rs = api.communicate(params, rq)
report_rows = rs['audit_event_overview_report_rows']
aram.raise_if_audit_report_failed(rs, 'user-report')
report_rows = rs.get('audit_event_overview_report_rows') or []
last_login.update({row.get('username', '').lower(): row.get('last_created') for row in report_rows})

get_fmt_dt = lambda x: dt_module.fromtimestamp(x, datetime.timezone.utc)
Expand Down Expand Up @@ -4335,14 +4336,14 @@ def execute(self, params, **kwargs):
'limit': 1000
}
rs = api.communicate(params, rq)
aram.raise_if_audit_report_failed(rs, 'device-approve')
ip_map = {}
if 'audit_event_overview_report_rows' in rs:
for row in rs['audit_event_overview_report_rows']:
if 'username' in row and 'ip_address' in row:
uname = row['username']
if uname not in ip_map:
ip_map[uname] = set()
ip_map[uname].add(row['ip_address'])
for row in rs.get('audit_event_overview_report_rows') or []:
if 'username' in row and 'ip_address' in row:
uname = row['username']
if uname not in ip_map:
ip_map[uname] = set()
ip_map[uname].add(row['ip_address'])

# Filter out users that tried to login from an untrusted IP
trusted_devices = {} # To avoid array modification in a loop, we will store this into a separated dict
Expand Down
31 changes: 23 additions & 8 deletions keepercommander/commands/helpers/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,29 @@ def user_has_privilege(params, privilege): # type: (KeeperParams, str) -> bool
return False

# Check role-derived privileges
username = params.user
users = enterprise.get('users')
e_user_id = next(iter([u.get('enterprise_user_id') for u in users if u.get('username') == username]))
role_users = enterprise.get('role_users')
r_ids = [ru.get('role_id') for ru in role_users if ru.get('enterprise_user_id') == e_user_id]
r_privileges = enterprise.get('role_privileges')
p_key = 'privilege'
return any(rp for rp in r_privileges if rp.get('role_id') in r_ids and rp.get(p_key) == privilege)
username = (params.user or '').casefold()
users = enterprise.get('users') or []
e_user_id = next(
(u.get('enterprise_user_id') for u in users
if (u.get('username') or '').casefold() == username),
None
)
if e_user_id is None:
return False

role_users = enterprise.get('role_users') or []
r_ids = {
ru.get('role_id') for ru in role_users
if ru.get('enterprise_user_id') == e_user_id
}
if not r_ids:
return False

r_privileges = enterprise.get('role_privileges') or []
return any(
rp.get('role_id') in r_ids and rp.get('privilege') == privilege
for rp in r_privileges
)

def get_enterprise_key(params, is_rsa=False):
keys = params.enterprise.get('keys', {})
Expand Down
5 changes: 2 additions & 3 deletions keepercommander/commands/ksm.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,7 @@ def _usage_metrics(params, **kwargs):
audit_filter = {'audit_event_type': [KSM_USAGE_EVENT_TYPE], 'created': created_filter}
rows = fetch_audit_events(
params, audit_filter, columns=['app_uid', 'device_name', 'username'],
aggregate=['occurrences'], report_type='span', limit=limit)
aggregate=['occurrences'], report_type='span', limit=limit, cmd='secrets-manager usage')

# Aggregation maps mirror Console getSecretsManagerMetrics. The *_apps maps track which
# application UID(s) contributed to each row so we can flag rows whose app has been deleted
Expand Down Expand Up @@ -928,7 +928,7 @@ def _usage_timeline(params, **kwargs):
audit_filter = {'audit_event_type': KSM_EVENT_TYPES, 'created': created_filter}
rows = fetch_audit_events(
params, audit_filter, columns=['audit_event_type'], aggregate=['occurrences'],
report_type=report_type, limit=limit, order='descending')
report_type=report_type, limit=limit, order='descending', cmd='secrets-manager usage')

def label(evt):
return AUDIT_EVENT_STATE_MAPPING.get(evt, evt)
Expand Down Expand Up @@ -2475,4 +2475,3 @@ def get_hash_of_one_time_token(one_time_token):
digest).digest())
return one_time_token_hash


Loading