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
35 changes: 35 additions & 0 deletions keepercommander/commands/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 3 additions & 5 deletions keepercommander/commands/record_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down
29 changes: 28 additions & 1 deletion keepercommander/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):]
Expand All @@ -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:<s}'.format(display_name, str(c['value'])))
# v3 custom fields without a label are stored as name='fieldType' (no colon)
if not is_secret_field and field_name.split(':')[0] in _MASKED_TYPES:
is_secret_field = True
if (field_name == 'securityQuestion' or
field_name.startswith('securityQuestion:') or
c.get('type') == 'securityQuestion'):
val = c['value']
entry = val[0] if (isinstance(val, list) and val) else val
if isinstance(entry, dict):
q = (entry.get('question') or '').rstrip('?').strip()
a = entry.get('answer') or ''
display_value = f'{q}? ' + (a if unmask else '********')
else:
display_value = str(c['value']) if unmask else '********'
else:
display_value = str(c['value']) if (unmask or not is_secret_field) else '********'
print('{0:>20s}: {1:<s}'.format(display_name, display_value))

if self.notes:
lines = self.notes.split('\n')
Expand Down
7 changes: 5 additions & 2 deletions keepercommander/recordv3.py
Original file line number Diff line number Diff line change
Expand Up @@ -1641,7 +1641,10 @@ def display(r, **kwargs):
if ftyp == 'name':
fval.append(vault.TypedField.export_name_field(x))
elif ftyp == 'securityQuestion':
fval.append(vault.TypedField.export_q_and_a_field(x))
q = (x.get('question') or '').replace('?', '').strip()
a = x.get('answer') or ''
# answer is masked by default (same as WV initialMasked=true)
fval.append(f'{q}? ' + (a if unmask else '********'))
elif ftyp == 'paymentCard':
n = x.get('cardNumber') or ''
if n and not unmask:
Expand Down Expand Up @@ -1719,7 +1722,7 @@ def display(r, **kwargs):
RecordV3.display_ref(ftyp, fval, **kwargs)
else:
is_masked = (ftyp not in record_types.RecordFields or
ftyp in ['password', 'pinCode', 'secret', 'note', 'oneTimeCode']) and not unmask
ftyp in ['password', 'pinCode', 'secret', 'note', 'oneTimeCode', 'json']) and not unmask
if is_masked:
print('{0:>20s}: {1:<s}'.format(fkey, '********'))
else:
Expand Down
18 changes: 14 additions & 4 deletions unit-tests/pam/test_dag_layer_b_configure_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


# --------------------------------------------------------------------------- #
Expand Down
Loading
Loading