From 53e2be65ca1c10b3325988f3240e000b6255e232 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Tue, 31 Mar 2026 12:18:47 -0400 Subject: [PATCH 1/4] APPSEC-9: replace pickle with JSON in Ansible cache serialization Replace unsafe pickle.loads() deserialization with JSON-based serialization in the record cache encrypt/decrypt path. Pickle deserialization of attacker-influenced data is a known RCE vector. The fix adds Record/KeeperFile to-dict/from-dict helpers that base64-encode bytes fields (record_key_bytes, file_data) and reconstruct full SDK object instances via object.__new__() to bypass __init__ (which expects encrypted server data). All 33 Ansible tests pass including 6 cache round-trip tests. --- .../__init__.py | 92 +++++++++++++++++-- 1 file changed, 85 insertions(+), 7 deletions(-) diff --git a/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/__init__.py b/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/__init__.py index eb41bb7c3..dd5685450 100644 --- a/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/__init__.py +++ b/integration/keeper_secrets_manager_ansible/keeper_secrets_manager_ansible/__init__.py @@ -21,8 +21,6 @@ import random from enum import Enum import traceback -import pickle -import io import base64 import socket @@ -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 @@ -306,16 +305,95 @@ 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)) + json_bytes = Fernet(secret_key).decrypt(ciphertext) + record_dicts = json.loads(json_bytes) + return [KeeperAnsible._record_from_dict(d) for d in record_dicts] @staticmethod def convert_records_into_dict(records): From a44f0da7f9cf9213315001cb1b2d1cdbb579a8b9 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Tue, 31 Mar 2026 12:44:16 -0400 Subject: [PATCH 2/4] =?UTF-8?q?chore(ansible):=20bump=20version=201.4.0=20?= =?UTF-8?q?=E2=86=92=201.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- integration/keeper_secrets_manager_ansible/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/keeper_secrets_manager_ansible/setup.py b/integration/keeper_secrets_manager_ansible/setup.py index 6c5f6da9b..cc1b7f8d2 100644 --- a/integration/keeper_secrets_manager_ansible/setup.py +++ b/integration/keeper_secrets_manager_ansible/setup.py @@ -16,7 +16,7 @@ setup( name="keeper-secrets-manager-ansible", - version='1.4.0', + version='1.5.0', description="Keeper Secrets Manager plugins for Ansible.", long_description=long_description, long_description_content_type="text/markdown", From 82b21e49f495de99482921616264d6c1d8b23bbd Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Thu, 9 Apr 2026 15:48:48 -0400 Subject: [PATCH 3/4] ci: add path filter to test.ansible.yml (integration/keeper_secrets_manager_ansible/**) --- .github/workflows/test.ansible.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.ansible.yml b/.github/workflows/test.ansible.yml index e1706adba..0c64f9395 100644 --- a/.github/workflows/test.ansible.yml +++ b/.github/workflows/test.ansible.yml @@ -3,6 +3,9 @@ name: Test-Ansible on: pull_request: branches: [ master ] + paths: + - 'integration/keeper_secrets_manager_ansible/**' + - '.github/workflows/test.ansible.yml' jobs: test-ansible: From 78e254e1e924332750936b38e9fcae76b4a4f448 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Thu, 9 Apr 2026 15:52:09 -0400 Subject: [PATCH 4/4] ci: add path filter to test.ansible.yml including sdk/python/core dependency --- .github/workflows/test.ansible.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.ansible.yml b/.github/workflows/test.ansible.yml index 0c64f9395..c8b141c53 100644 --- a/.github/workflows/test.ansible.yml +++ b/.github/workflows/test.ansible.yml @@ -5,6 +5,7 @@ on: branches: [ master ] paths: - 'integration/keeper_secrets_manager_ansible/**' + - 'sdk/python/core/**' - '.github/workflows/test.ansible.yml' jobs: