From ea350e6856ad5b870dfa4cf56395a2e63d54cd3a Mon Sep 17 00:00:00 2001 From: Ivan Dimov <78815270+idimov-keeper@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:57:04 -0500 Subject: [PATCH 1/2] get: mask secret-type custom fields in detail/fields output; fix awspswd import --- keepercommander/commands/record.py | 35 ++++ keepercommander/commands/record_edit.py | 8 +- keepercommander/record.py | 29 +++- keepercommander/recordv3.py | 7 +- unit-tests/test_command_record.py | 216 ++++++++++++++++++++++++ 5 files changed, 287 insertions(+), 8 deletions(-) diff --git a/keepercommander/commands/record.py b/keepercommander/commands/record.py index 99355974c..7977d0ab5 100644 --- a/keepercommander/commands/record.py +++ b/keepercommander/commands/record.py @@ -635,6 +635,41 @@ def _format_expiration(expiration_value): 'value': r.notes, } fields.append(field) + # custom fields (v2 type in c['type']; v3 encode type as "type:label" in c['name']) + # Keep _MASKED_FIELD_TYPES in sync with _MASKED_TYPES in record.py. + # 'note' = Secured Note — sensitive, masked by design. + # 'passkey' omitted: early-exit handler renders only non-sensitive sub-fields. + _MASKED_FIELD_TYPES = frozenset({ + 'secret', 'pinCode', 'note', 'json', 'oneTimeCode', + 'paymentCard', 'bankAccount', 'keyPair', 'securityQuestion', + }) + unmask = kwargs.get('unmask') is True + for cf in (r.custom_fields or []): + cf_value = cf.get('value') + if not cf_value and cf_value != 0: + continue + cf_name = str(cf.get('name') or cf.get('type') or '') + is_sensitive = (cf.get('type') in _MASKED_FIELD_TYPES or + cf_name.split(':')[0] in _MASKED_FIELD_TYPES) + is_sq = (cf.get('type') == 'securityQuestion' or + cf_name == 'securityQuestion' or + cf_name.startswith('securityQuestion:')) + if is_sq: + val = cf_value + entry = val[0] if (isinstance(val, list) and val) else val + if isinstance(entry, dict): + display_val = { + 'question': entry.get('question') or '', + 'answer': (entry.get('answer') or '') if unmask else '********', + } + else: + display_val = cf_value if unmask else '********' + else: + display_val = cf_value if (unmask or not is_sensitive) else '********' + fields.append({ + 'name': cf_name, + 'value': display_val, + }) print(json.dumps(fields, indent=2)) else: diff --git a/keepercommander/commands/record_edit.py b/keepercommander/commands/record_edit.py index 286b0527c..9781bf431 100644 --- a/keepercommander/commands/record_edit.py +++ b/keepercommander/commands/record_edit.py @@ -1035,7 +1035,7 @@ def _sync_password_to_pam(self, params: KeeperParams, record: vault.TypedRecord, if plugin_name == 'azureadpwd': # Import Azure AD plugin - from ...plugins.azureadpwd import azureadpwd + from ..plugins.azureadpwd import azureadpwd # Call the rotate function with PAM config record success = azureadpwd.rotate(pam_record, password) @@ -1047,7 +1047,7 @@ def _sync_password_to_pam(self, params: KeeperParams, record: vault.TypedRecord, elif plugin_name == 'awspswd': # Import AWS plugin and common rotator - from ...plugins.awspswd import aws_passwd + from ..plugins.awspswd import aws_passwd # Extract AWS credentials from PAM config aws_access_key = None @@ -1078,7 +1078,6 @@ def _sync_password_to_pam(self, params: KeeperParams, record: vault.TypedRecord, # Set AWS credentials in environment or use profile if aws_access_key and aws_secret_key: - import os original_access_key = os.environ.get('AWS_ACCESS_KEY_ID') original_secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY') @@ -1145,7 +1144,6 @@ def _send_onboarding_email( # Load email configuration from .email_commands import find_email_config_record, load_email_config_from_record from ..email_service import EmailSender, build_onboarding_email - from .helpers.timeout import parse_timeout config_uid = find_email_config_record(params, email_config_name) if not config_uid: @@ -1170,7 +1168,7 @@ def _send_onboarding_email( else: # minutes minutes = expire_seconds // 60 expiration_text = f"{minutes} minute{'s' if minutes > 1 else ''}" - except: + except Exception: expiration_text = expiration # fallback to original if parsing fails html_body = build_onboarding_email( diff --git a/keepercommander/record.py b/keepercommander/record.py index 897180003..189e3ccf0 100644 --- a/keepercommander/record.py +++ b/keepercommander/record.py @@ -275,6 +275,15 @@ def display(self, unmask=False): # Strip type prefixes from field names (e.g., "text:Sign-In Address" -> "Sign-In Address") field_type_prefixes = ('text:', 'multiline:', 'url:', 'phone:', 'email:', 'secret:', 'date:', 'name:', 'host:', 'address:') display_name = field_name + # v2 records carry the real type in c['type']; v3 encode it as "type:label" in the name. + # Keep this list in sync with _MASKED_FIELD_TYPES in commands/record.py. + # 'note' = Secured Note — sensitive, masked by design. + # 'passkey' omitted: early-exit handler above renders only non-sensitive sub-fields. + _MASKED_TYPES = frozenset({ + 'secret', 'pinCode', 'note', 'json', 'oneTimeCode', + 'paymentCard', 'bankAccount', 'keyPair', 'securityQuestion', + }) + is_secret_field = c.get('type') in _MASKED_TYPES for prefix in field_type_prefixes: if field_name.lower().startswith(prefix): display_name = field_name[len(prefix):] @@ -293,8 +302,26 @@ def display(self, unmask=False): 'address:': 'Address', } display_name = type_friendly_names.get(prefix, prefix.rstrip(':').title()) + if prefix.rstrip(':') in _MASKED_TYPES: + is_secret_field = True break - print('{0:>20s}: {1:20s}: {1:20s}: {1: Date: Thu, 11 Jun 2026 21:12:44 -0500 Subject: [PATCH 2/2] fix flaky body-encryption assertion in test_dag_layer_b_configure_resource --- .../pam/test_dag_layer_b_configure_resource.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/unit-tests/pam/test_dag_layer_b_configure_resource.py b/unit-tests/pam/test_dag_layer_b_configure_resource.py index 13f36247e..c29e1b2d2 100644 --- a/unit-tests/pam/test_dag_layer_b_configure_resource.py +++ b/unit-tests/pam/test_dag_layer_b_configure_resource.py @@ -102,15 +102,25 @@ def test_configure_resource_hits_correct_url(): def test_configure_resource_sends_protobuf_body(): - """Body is the encrypted PAMResourceConfig protobuf, not JSON.""" + """Body is the AES-GCM-encrypted PAMResourceConfig protobuf — not plaintext proto, not JSON.""" from keepercommander.commands.pam.router_helper import router_configure_resource + from keepercommander import crypto, utils rq = pam_pb2.PAMResourceConfig(recordUid=RESOURCE_UID, networkUid=NETWORK_UID, adminUid=ADMIN_UID) + transmission_key = utils.generate_aes_key() with patch(REQUESTS_TARGET, return_value=_ok_router_response()) as mock_req: - router_configure_resource(_mock_params(), rq) + router_configure_resource(_mock_params(), rq, transmission_key=transmission_key) _, body = _capture_call(mock_req) assert isinstance(body, (bytes, bytearray)), f'body must be bytes, got {type(body)}' - # Body is encrypted; check that it's NOT a JSON-encoded payload (sanity). - assert not body.startswith(b'{'), 'body should be encrypted protobuf, not JSON' + # Must not be the raw (unencrypted) serialisation. + # (A first-byte heuristic like `not body.startswith(b'{')` is flaky: ~1/256 + # of ciphertexts legitimately start with 0x7b.) + assert body != rq.SerializeToString() + decrypted = crypto.decrypt_aes_v2(body, transmission_key) + parsed = pam_pb2.PAMResourceConfig() + parsed.ParseFromString(decrypted) + assert parsed.recordUid == RESOURCE_UID + assert parsed.networkUid == NETWORK_UID + assert parsed.adminUid == ADMIN_UID # --------------------------------------------------------------------------- #