Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/test.ansible.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ name: Test-Ansible
on:
pull_request:
branches: [ master ]
paths:
- 'integration/keeper_secrets_manager_ansible/**'
- 'sdk/python/core/**'
- '.github/workflows/test.ansible.yml'

jobs:
test-ansible:
Expand Down
11 changes: 11 additions & 0 deletions integration/keeper_secrets_manager_ansible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ For more information see our official documentation page https://docs.keeper.io/

# Changes

## 1.5.0
* **Fix**: `keeper_create` crashed with "Could not create record: list index out of range" when a playbook supplied an unpopulated complex field (address, name, host, etc.) with `value: []`. Empty-value fields are now treated as unpopulated, matching the behavior of the underlying vault schema. Root cause in the Python helper library is tracked as KSM-1119.
* KSM-845: Added `folder_uid` parameter to `keeper_create` for subfolder targeting
- Records can now be created in a subfolder within a shared folder, rather than always at the shared folder root
- `shared_folder_uid` remains required; `folder_uid` is optional and additive
* **Security**: VM-1452 / CWE-502 — Replaced pickle with JSON for encrypted record cache serialization
- Cache encrypt/decrypt no longer uses `pickle.loads`, removing insecure deserialization risk
- Legacy or invalid registered caches are ignored; records are fetched from the vault until
`keeper_cache_records` rebuilds a JSON cache
- Existing playbook-registered caches are ephemeral; regenerate with `keeper_cache_records` after upgrade

## 1.4.0
* KSM-827: Fixed Tower Execution Environment Docker image missing system packages required by AAP
- Added `openssh-clients`, `sshpass`, `rsync`, and `git` to `additional_build_packages` in `execution-environment.yml`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ configuration file or even a playbook.

# Changes

## 1.5.0
* KSM-845: Added `folder_uid` parameter to `keeper_create` for subfolder targeting
- Records can now be created in a subfolder within a shared folder, rather than always at the shared folder root
- `shared_folder_uid` remains required; `folder_uid` is optional and additive
* **Security**: VM-1452 / CWE-502 — Replaced pickle with JSON for encrypted record cache serialization
- Cache encrypt/decrypt no longer uses `pickle.loads`, removing insecure deserialization risk
- Legacy or invalid registered caches are ignored; records are fetched from the vault until
`keeper_cache_records` rebuilds a JSON cache
- Existing playbook-registered caches are ephemeral; regenerate with `keeper_cache_records` after upgrade

## 1.4.0
* KSM-827: Fixed Tower Execution Environment Docker image missing system packages required by AAP
- Added `openssh-clients`, `sshpass`, `rsync`, and `git` to the EE image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
import random
from enum import Enum
import traceback
import pickle
import io
import base64
import socket

Expand All @@ -37,6 +35,7 @@
from keeper_secrets_manager_core.core import KSMCache, CreateOptions
from keeper_secrets_manager_core.storage import FileKeyValueStorage, InMemoryKeyValueStorage
from keeper_secrets_manager_core.utils import generate_password as sdk_generate_password, strtobool
from keeper_secrets_manager_core.dto.dtos import Record as _Record, KeeperFile as _KeeperFile

# If keeper_secrets_manager_core is installed, then these will be installed. They are deps.
from cryptography.fernet import Fernet
Expand All @@ -47,6 +46,10 @@
display = Display()


class CacheUnusableError(ValueError):
"""Encrypted record cache cannot be decrypted or deserialized; treat as a cache miss."""


class KeeperFieldType(Enum):
FIELD = "field"
CUSTOM_FIELD = "custom_field"
Expand Down Expand Up @@ -306,16 +309,125 @@ def get_encryption_key(self):

return base64.urlsafe_b64encode(kdf.derive(cache_secret.encode()))

def encrypt(self, data):
@staticmethod
def _file_to_dict(keeper_file):
"""Serialize a KeeperFile instance to a JSON-safe dictionary."""
d = {
"name": keeper_file.name,
"title": keeper_file.title,
"type": keeper_file.type,
"last_modified": keeper_file.last_modified,
"size": keeper_file.size,
"f": keeper_file.f,
"file_key": keeper_file.file_key,
"meta_dict": keeper_file.meta_dict,
}
d["record_key_bytes"] = base64.b64encode(keeper_file.record_key_bytes).decode("ascii") \
if keeper_file.record_key_bytes is not None else None
d["file_data"] = base64.b64encode(keeper_file.file_data).decode("ascii") \
if keeper_file.file_data is not None else None
return d

@staticmethod
def _file_from_dict(d):
"""Reconstruct a KeeperFile instance from a JSON-deserialized dictionary."""
f = object.__new__(_KeeperFile)
f.name = d["name"]
f.title = d["title"]
f.type = d["type"]
f.last_modified = d["last_modified"]
f.size = d["size"]
f.f = d["f"]
f.file_key = d["file_key"]
f.meta_dict = d["meta_dict"]
f.record_key_bytes = base64.b64decode(d["record_key_bytes"]) \
if d["record_key_bytes"] is not None else None
f.file_data = base64.b64decode(d["file_data"]) \
if d["file_data"] is not None else None
return f

@staticmethod
def _record_to_dict(record):
"""Serialize a Record instance to a JSON-safe dictionary."""
d = {
"uid": record.uid,
"title": record.title,
"type": record.type,
"raw_json": record.raw_json,
"dict": record.dict,
"password": record.password,
"revision": record.revision,
"is_editable": record.is_editable,
"folder_uid": record.folder_uid,
"inner_folder_uid": record.inner_folder_uid,
"links": record.links,
"files": [KeeperAnsible._file_to_dict(f) for f in record.files],
}
d["record_key_bytes"] = base64.b64encode(record.record_key_bytes).decode("ascii") \
if record.record_key_bytes is not None else None
return d

@staticmethod
def _record_from_dict(d):
"""Reconstruct a Record instance from a JSON-deserialized dictionary."""
r = object.__new__(_Record)
r.uid = d["uid"]
r.title = d["title"]
r.type = d["type"]
r.raw_json = d["raw_json"]
r.dict = d["dict"]
r.password = d["password"]
r.revision = d["revision"]
r.is_editable = d["is_editable"]
r.folder_uid = d["folder_uid"]
r.inner_folder_uid = d["inner_folder_uid"]
r.links = d["links"]
r.record_key_bytes = base64.b64decode(d["record_key_bytes"]) \
if d["record_key_bytes"] is not None else None
r.files = [KeeperAnsible._file_from_dict(fd) for fd in d["files"]]
return r

def encrypt(self, data):
secret_key = self.get_encryption_key()
record_fh = io.BytesIO()
pickle.dump(data, record_fh)
return Fernet(secret_key).encrypt(record_fh.getvalue())
serializable = [KeeperAnsible._record_to_dict(r) for r in data]
json_bytes = json.dumps(serializable).encode("utf-8")
return Fernet(secret_key).encrypt(json_bytes)

def decrypt(self, ciphertext):
secret_key = self.get_encryption_key()
return pickle.loads(Fernet(secret_key).decrypt(ciphertext))
try:
plaintext = Fernet(secret_key).decrypt(ciphertext)
except Exception as err:
raise CacheUnusableError(
"Unable to decrypt the record cache. Check keeper_record_cache_secret "
"or regenerate the cache with keeper_cache_records."
) from err

# Pickle protocol markers (e.g. 0x80) -- never call pickle.loads (CWE-502 / VM-1452).
if plaintext.startswith(b"\x80"):
raise CacheUnusableError(
"Unable to deserialize the record cache. The cache may be from an older "
"plugin version or is invalid. Regenerate the cache with keeper_cache_records."
)

try:
payload = json.loads(plaintext.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as err:
raise CacheUnusableError(
"Unable to deserialize the record cache. The cache may be from an older "
"plugin version or is invalid. Regenerate the cache with keeper_cache_records."
) from err

if not isinstance(payload, list):
raise CacheUnusableError(
"Unable to deserialize the record cache. Expected a list of records. "
"Regenerate the cache with keeper_cache_records."
)

try:
return [KeeperAnsible._record_from_dict(d) for d in payload]
except (KeyError, TypeError, ValueError) as err:
raise CacheUnusableError(str(err)) from err

@staticmethod
def convert_records_into_dict(records):
Expand Down Expand Up @@ -460,7 +572,15 @@ def get_records_from_cache(self, cache, uids=None, titles=None):
def get_records(self, uids=None, titles=None, cache=None, encrypt=False):

if cache is not None:
records = self.get_records_from_cache(cache, uids=uids, titles=titles)
try:
records = self.get_records_from_cache(cache, uids=uids, titles=titles)
except CacheUnusableError:
# Invalidate legacy/invalid cache and start from scratch via the vault.
display.warning(
"Keeper record cache is unusable (legacy or invalid format) and was ignored. "
"Fetching records from the vault. Regenerate the cache with keeper_cache_records."
)
records = self.get_records_from_vault(uids=uids, titles=titles, encrypt=encrypt)
else:
records = self.get_records_from_vault(uids=uids, titles=titles, encrypt=encrypt)

Expand All @@ -477,14 +597,14 @@ def get_record(self, uids=None, titles=None, cache=None):

return records[0]

def create_record(self, new_record, shared_folder_uid):
def create_record(self, new_record, shared_folder_uid, folder_uid=None):
# KSM-816: use create_secret_with_options() instead of create_secret() so
# that folder keys are fetched via the get_folders endpoint, which returns
# all folders including empty ones. create_secret() uses get_secrets() which
# only returns folder keys when the folder already contains records.
try:
record_uid = self.client.create_secret_with_options(
CreateOptions(shared_folder_uid, None), new_record
CreateOptions(shared_folder_uid, folder_uid), new_record
)
except Exception as err:
raise Exception("Cannot get create record: {}".format(err))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,16 @@
shared_folder_uid:
description:
- The UID of the top-level shared folder in your Keeper application.
- Must be a shared folder UID, not a subfolder UID.
- To create in a subfolder, also provide C(folder_uid).
type: str
required: yes
folder_uid:
description:
- The UID of a subfolder within the shared folder where the record should be created.
- When omitted, the record is created at the shared folder root.
- The subfolder must already exist and be accessible to the KSM application.
type: str
required: no
record_type:
description:
- The type if record to create.
Expand Down Expand Up @@ -204,9 +211,9 @@
'''

EXAMPLES = r'''
- name: Create a new record
- name: Create a record in a shared folder
keeper_create:
share_folder_uid: XXX
shared_folder_uid: SHARED_FOLDER_UID
record_type: login
title: My Title
notes: This record was created from Ansible
Expand All @@ -221,6 +228,18 @@
label: Custom Field
value: This is a value is a custom field.
register: my_new_record

- name: Create a record in a subfolder
keeper_create:
shared_folder_uid: SHARED_FOLDER_UID
folder_uid: SUBFOLDER_UID
record_type: login
title: My Subfolder Record
generate_password: True
fields:
- type: login
value: jane.doe@nowhere.com
register: my_subfolder_record
'''

RETURN = r'''
Expand All @@ -245,6 +264,7 @@ def run(self, tmp=None, task_vars=None):
shared_folder_uid = self._task.args.get("shared_folder_uid")
if shared_folder_uid is None:
raise AnsibleError("The shared_folder_uid is blank. keeper_create requires this value to be set.")
folder_uid = self._task.args.get("folder_uid")
record_type = self._task.args.get("record_type")
if record_type is None:
raise AnsibleError("The record_type is blank. keeper_create requires this value to be set.")
Expand Down Expand Up @@ -272,20 +292,24 @@ def run(self, tmp=None, task_vars=None):

try:
for field in self._task.args.get("fields", []):
# Workaround: convert value: [] to None so helper FieldType.__init__ skips the
# dict-field index (value[0]) that crashes on empty lists. Remove once helper
# ships the "if self.value:" guard in FieldType.__init__.
fields.append(Field(
field_section=FieldSectionEnum.STANDARD,
type=field.get("type"),
label=field.get("label"),
value=field.get("value")
value=field.get("value") or None
))
keeper.stash_secret_value(str(field.get("value")))

for field in self._task.args.get("custom_fields", []):
# Same workaround as above for custom fields.
fields.append(Field(
field_section=FieldSectionEnum.CUSTOM,
type=field.get("type"),
label=field.get("label"),
value=field.get("value", "text")
value=field.get("value", "text") or None
))
keeper.stash_secret_value(str(field.get("value")))

Expand Down Expand Up @@ -313,7 +337,8 @@ def run(self, tmp=None, task_vars=None):
password_complexity=password_complexity
)
record_create = record[0].get_record_create_obj()
record_uid = keeper.create_record(record_create, shared_folder_uid=shared_folder_uid)
record_uid = keeper.create_record(record_create, shared_folder_uid=shared_folder_uid,
folder_uid=folder_uid)
except Exception as err:
raise AnsibleError("Could not create record: {}".format(err))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,16 @@
shared_folder_uid:
description:
- The UID of the top-level shared folder in your Keeper application.
- Must be a shared folder UID, not a subfolder UID.
- To create in a subfolder, also provide C(folder_uid).
type: str
required: yes
folder_uid:
description:
- The UID of a subfolder within the shared folder where the record should be created.
- When omitted, the record is created at the shared folder root.
- The subfolder must already exist and be accessible to the KSM application.
type: str
required: no
record_type:
description:
- The type if record to create.
Expand Down Expand Up @@ -182,9 +189,9 @@
'''

EXAMPLES = r'''
- name: Create a new record
- name: Create a record in a shared folder
keeper_create:
share_folder_uid: XXX
shared_folder_uid: SHARED_FOLDER_UID
record_type: login
title: My Title
notes: This record was created from Ansible
Expand All @@ -199,6 +206,18 @@
label: Custom Field
value: This is a value is a custom field.
register: my_new_record

- name: Create a record in a subfolder
keeper_create:
shared_folder_uid: SHARED_FOLDER_UID
folder_uid: SUBFOLDER_UID
record_type: login
title: My Subfolder Record
generate_password: True
fields:
- type: login
value: jane.doe@nowhere.com
register: my_subfolder_record
'''

RETURN = r'''
Expand Down
4 changes: 2 additions & 2 deletions integration/keeper_secrets_manager_ansible/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
ansible-core>=2.12.0
keeper-secrets-manager-core>=17.2.0
keeper-secrets-manager-helper>=1.1.0
keeper-secrets-manager-core>=17.3.0
keeper-secrets-manager-helper>=1.1.2
Loading
Loading