Защита секретов Ansible с помощью SOPS
CNCF SOPS позволяет шифровать и расшифровывать файлы с помощью различных источников ключей (GPG, AWS KMS, GCP KMS, …). Для структурированных данных, таких как файлы YAML, JSON, INI и ENV, он шифрует значения, но не ключи отображения. В файлах YAML он также шифрует комментарии. Это делает SOPS отличным инструментом для шифрования учетных данных с помощью Ansible: вы можете легко увидеть, в каких файлах содержится та или иная переменная, но сами переменные будут зашифрованы.
Возможность использовать различные источники ключей упрощает работу в сложных средах по сравнению с Ansible Vault.
- Установка SOPS
- Установка community.sops в среде выполнения
- Настройка SOPS
- Работа с зашифрованными файлами
- Работа с зашифрованными данными из других источников
- Работа с зашифрованными переменными
Установка SOPS
Бинарные файлы и пакеты можно найти на странице выпусков проекта. В зависимости от вашей операционной системы, возможно, его также можно установить с помощью системного менеджера пакетов.
Эта коллекция предоставляет роль community.sops.install, которая позволяет установить SOPS и GNU Privacy Guard (GPG). Роль позволяет установить SOPS из системного менеджера пакетов или с GitHub; подробности см. в sops_source. SOPS и GPG можно установить на удаленных узлах или на контроллере Ansible; подробности см. в sops_install_on_localhost.
- name: Playbook to install SOPS
hosts: all
tasks:
# To use the sops_encrypt module on a remote host, you need to install SOPS on it:
- name: Install SOPS on remote hosts
ansible.builtin.include_role:
name: community.sops.install
vars:
sops_version: 2.7.0 # per default installs the latest version
# To use the lookup plugin, filter plugin, vars plugin, or the load_vars action,
# you need SOPS installed on localhost:
- name: Install SOPS on localhost
ansible.builtin.include_role:
name: community.sops.install
vars:
sops_install_on_localhost: true
При использовании ansible-core 2.11 или более поздней версии можно также воспользоваться двумя вспомогательными плейбуками:
# Install SOPS on Ansible controller $ ansible-playbook community.sops.install_localhost # Install SOPS on remote servers $ ansible-playbook community.sops.install --inventory /path/to/inventory
Установка community.sops в среде выполнения
При сборке среды выполнения, содержащей community.sops, обратите внимание: по умолчанию SOPS не устанавливается автоматически. Это связано с ограничением системы описания зависимостей для сред выполнения. Если вы собираете среду выполнения, содержащую community.sops, убедитесь, что SOPS установлен в ней.
Самый простой способ обеспечить это — использовать плейбук community.sops.install_localhost. При описании среды выполнения можно добавить RUN дополнительный шаг сборки в execution-environment.yml:
---
version: 3
dependencies:
galaxy: requirements.yml
additional_build_steps:
append_final:
# Ensure that SOPS is installed in the EE, assuming the EE is for ansible-core 2.11 or newer
- RUN ansible-playbook -v community.sops.install_localhost
Обратите внимание, что это работает только в том случае, если среда выполнения собрана с ansible-core 2.11 или новее. При использовании среды выполнения с Ansible 2.9 необходимо вручную применить роль community.sops.install. Также обратите внимание, что необходимо убедиться, что Ansible 2.9 использует правильный интерпретатор Python для установки системных пакетов; в приведенном ниже примере предполагается, что базовый образ среды выполнения основан на RHEL/CentOS:
---
version: 3
dependencies:
galaxy: requirements.yml
additional_build_steps:
append_final:
# Special step needed for Ansible 2.9 based EEs
- >-
RUN ansible localhost -m include_role -a name=community.sops.install
-e sops_install_on_localhost=true
-e ansible_python_interpreter=/usr/libexec/platform-python
После выполнения этого шага вы сможете использовать все плагины и модули community.sops в среде выполнения (на localhost).
Настройка SOPS
Далее в этом руководстве предполагается, что SOPS уже установлен.
Для простоты можно использовать ключи GPG. Если у вас нет такого ключа или вы не хотите использовать свой, выполните gpg --quick-generate-key me@example.com, чтобы создать ключ GPG для идентификатора пользователя me@example.com. Вам понадобится его 40-значный шестнадцатеричный идентификатор ключа, который будет напечатан в конце. Сначала создайте файл .sops.yaml в дереве каталогов, с которым вы работаете:
creation_rules: - pgp: 'FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4'
Здесь FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4 — это 40-значный шестнадцатеричный идентификатор ключа. С помощью этого файла можно создать зашифрованный SOPS файл, выполнив следующую команду в каталоге, где находится .sops.yaml, или в его подкаталоге:
$ sops test.sops.yaml
Откроется окно редактора с примером файла YAML. Вставьте следующее содержимое:
# This is a comment hello: world foo: - bar - baz
После закрытия редактора SOPS создаст test.sops.yaml с зашифрованным содержимым:
#ENC[AES256_GCM,data:r6Ok05DzzHBO4tonlz2t49CF,iv:Y0P39iXwaGYU9NG5oRC3NuaGVL40uruSze0CxbDTpTk=,tag:EzoG+X+BJAHbxE0asSyGlQ==,type:comment]
hello: ENC[AES256_GCM,data:onBZqWk=,iv:bwj4bwaeh3vpVDYqY2AnYo1thF955i5vbFpCC1DwJtM=,tag:4qbVzuHTaPrXm64r2Rqz1Q==,type:str]
foo:
- ENC[AES256_GCM,data:UsY8,iv:USv71rKfvbTF+3a5T2WO56wGVu609/0uigqkO0pa6U4=,tag:s8NdqLp+8OOQg4xDfE78oA==,type:str]
- ENC[AES256_GCM,data:Dhmo,iv:qWs5gN2SCXYq0EfGelZhODsdViKB9w2taQMhsqy0D2g=,tag:I+ZFvuxnsvQmywqz+a/M9w==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age: []
lastmodified: "2021-06-15T19:36:34Z"
mac: ENC[AES256_GCM,data:HAvLeOvt7xWI7B5TCeDEsL6sOSzGGeTbgBSJaZkwadmoAm3Ny4IZPF8JAbFaPPLmN8FJVAt4D61aIWa6Xwi3xMj1g6DmxFfgK6JFJqWqW122UlMhqZ/WuMWFV6yVxpTLDXgemndgGDJqUTUi14FMh/MzPDg4f6kFP64kA9fpLrY=,iv:LdhswnMymZG8J9na/jnF3WYnX0DvzvoBlvjUCu4nI6c=,tag:Qt4d7L3FXsgfmg9iOs8P4A==,type:str]
pgp:
- created_at: "2021-06-15T19:36:01Z"
enc: |-
-----BEGIN PGP MESSAGE-----
wcBMAyUpShfNkFB/AQgAT8OAKnWLBQRG3kT5lZCmyoPzK6RwF0zRkwCzJkLNl6xg
nQjUjpD03ZD4FtiRidspXEj7NvCLDghJ0UETtDjmrwsTeJ5YAK/JxouWmoNhVVdF
p0qOlj/THXIV+ypVaqrisZGZiTqeWjUNFuayknvjm3XduOOPZA1MIJ14pQxcgca4
NWmKwPwXTWEy3RJ0ZsnjjjYvKHjHyvbHdbDgARu8R1jEgdNPKPBRVpEY6RNeafXI
gFBVRfrhPKD6HmnmNvjHwUc/K+wOa1ciIYVrT4mPXoyBsFkyV0egh/QRf0JO8+X7
Ut/jEtCrl9BXJCNYGmC5EU3PPiFlAu1MRxlCiPNWltLmASn2w62wMpgih6f+OpI/
zyEOdz0qx80LEfhv3+jBbDfBwz4GqpAHUr0fCXDzeDiKfzlU6isagoIAhJfwX6oG
NeQ47ktk1XhPmgIwxxuvonG14iQoU2cA
=GoXQ
-----END PGP MESSAGE-----
fp: FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4
unencrypted_suffix: _unencrypted
version: 3.7.1
Первая строка содержит зашифрованное содержимое. Вторая строка содержит незашифрованный ключ hello: world и зашифрованное строковое значение world. В следующих нескольких строках содержится незашифрованный ключ foo с зашифрованными элементами списка.
В конце раздел sops содержит метаданные, в том числе закрытый ключ, необходимый для расшифровки файла, зашифрованного открытым ключом с идентификатором ключа GPG FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4. Если настроено несколько ключей GPG или используются другие источники ключей, здесь также можно найти секретный ключ файла, зашифрованный с помощью этих ключей.
Работа с зашифрованными файлами
Расшифровывать файлы, зашифрованные с помощью SOPS, можно с помощью плагина поиска community.sops.sops, а динамически шифровать данные — с помощью модуля community.sops.sops_encrypt. Возможность шифровать данные полезна при создании или обновлении секретов в плейбуках Ansible.
Предположим, у вас есть зашифрованный закрытый ключ keys/private_key.pem.sops, который до шифрования с помощью SOPS был в формате PEM:
$ openssl genrsa -out keys/private_key.pem 2048 $ sops --encrypt keys/private_key.pem > keys/private_key.pem.sops $ wipe keys/private_key.pem
Чтобы использовать его в плейбуке, например передать модулю community.crypto.openssl_csr для создания запроса на подпись сертификата (CSR), можно загрузить его с помощью плагина поиска community.sops.sops:
---
- name: Load SOPS-encrypted private key
hosts: localhost
gather_facts: false
tasks:
- name: Create CSR with encrypted private key
community.crypto.openssl_csr:
# The private key is provided with SOPS:
privatekey_content: "{{ lookup('community.sops.sops', 'keys/private_key.pem.sops') }}"
# Store the CSR on disk unencrypted:
path: ansible.com.csr
# This is going to be a CSR for ansible.com and www.ansible.com
subject_alt_name:
- DNS:ansible.com
- DNS:www.ansible.com
use_common_name_for_san: false
В результате получится следующий вывод:
PLAY [Load SOPS-encrypted private key] *************************************************************************** TASK [Create CSR with encrypted private key] ********************************************************************* ok: [localhost] PLAY RECAP ******************************************************************************************************* localhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
После этого у вас будет CSR ansible.com.csr для зашифрованного закрытого ключа keys/private_key.pem.sops.
Если вы хотите использовать Ansible для создания (или обновления) зашифрованного закрытого ключа, воспользуйтесь модулем community.crypto.openssl_privatekey_pipe для создания (или обновления) закрытого ключа, а затем модулем community.sops.sops_encrypt, чтобы записать его на диск в зашифрованном виде:
---
- name: Create SOPS-encrypted private key
hosts: localhost
gather_facts: false
tasks:
- block:
- name: Create private key
community.crypto.openssl_privatekey_pipe:
size: 2048
no_log: true # Always use this with openssl_privatekey_pipe!
register: private_key
- name: Write encrypted key to disk
community.sops.sops_encrypt:
path: keys/private_key.pem.sops
content_text: "{{ private_key.privatekey }}"
always:
- name: Wipe private key from Ansible's facts
# This is particularly important if the playbook doesn't end here!
set_fact:
private_key: ''
Этот плейбук создает новый ключ при каждом запуске. Чтобы создание закрытого ключа было идемпотентным, потребуется выполнить еще несколько действий:
---
- name: Create SOPS-encrypted private key
hosts: localhost
gather_facts: false
tasks:
- block:
- name: Create private key
community.crypto.openssl_privatekey_pipe:
size: 2048
content: >-
{{ lookup(
'community.sops.sops',
'keys/private_key.pem.sops',
empty_on_not_exist=true
) }}
no_log: true # Always use this with openssl_privatekey_pipe!
register: private_key
- name: Write encrypted key to disk
community.sops.sops_encrypt:
path: keys/private_key.pem.sops
content_text: "{{ private_key.privatekey }}"
when: private_key is changed
always:
- name: Wipe private key from Ansible's facts
# This is particularly important if the playbook doesn't end here!
set_fact:
private_key: ''
Флаг empty_on_not_exist=true нужен, чтобы предотвратить ошибку поиска, если ключ еще не существует. При повторном запуске этого плейбука вывод будет следующим:
PLAY [Create SOPS-encrypted private key] ************************************************************************* TASK [Create private key] **************************************************************************************** ok: [localhost] TASK [Write encrypted key to disk] ******************************************************************************* skipping: [localhost] TASK [Wipe private key from Ansible's facts] ********************************************************************* ok: [localhost] PLAY RECAP ******************************************************************************************************* localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
Работа с зашифрованными данными из других источников
Для расшифровки произвольных данных можно использовать фильтр Jinja2 community.sops.decrypt. Это могут быть данные, ранее прочитанные из файла, возвращенные действием или полученные каким-либо иным способом.
Например, предположим, что вы хотите расшифровать файл, полученный с HTTPS-сервера с помощью модуля ansible.builtin.uri. Чтобы использовать поиск community.sops.sops, сначала нужно записать файл на диск. С помощью фильтра можно расшифровать его напрямую:
---
- name: Decrypt file fetched from URL
hosts: localhost
gather_facts: false
tasks:
- name: Fetch file from URL
ansible.builtin.uri:
url: https://raw.githubusercontent.com/getsops/sops/master/functional-tests/res/comments.enc.yaml
return_content: true
register: encrypted_content
- name: Show encrypted data
debug:
msg: "{{ encrypted_content.content | ansible.builtin.from_yaml }}"
- name: Decrypt data and decode decrypted YAML
set_fact:
decrypted_data: "{{ encrypted_content.content | community.sops.decrypt | ansible.builtin.from_yaml }}"
- name: Show decrypted data
debug:
msg: "{{ decrypted_data }}"
Результат будет следующим:
PLAY [Decrypt file fetched from URL] *****************************************************************************
TASK [Fetch file from URL] ***************************************************************************************
ok: [localhost]
TASK [Show encrypted data] ***************************************************************************************
ok: [localhost] => {
"msg": {
"dolor": "ENC[AES256_GCM,data:IgvT,iv:wtPNYbDTARFE810PH6ldOLzCDcAjkB/dzPsZjpgHcko=,tag:zwE8P+AwO1hrHkgF6pTbZw==,type:str]",
"lorem": "ENC[AES256_GCM,data:PhmSdTs=,iv:J5ugEWq6RfyNx+5zDXvcTdoQ18YYZkqesDED7LNzou4=,tag:0Qrom6J6aUnZMZzGz5XCxw==,type:str]",
"sops": {
"age": [],
"azure_kv": [],
"gcp_kms": [],
"hc_vault": [],
"kms": [],
"lastmodified": "2020-10-07T15:49:13Z",
"mac": "ENC[AES256_GCM,data:2dhyKdHYSynjXPwYrn9356wA7vRKw+T5qwBenI2vZrgthpQBOCQG4M6f7eeH3VLTxB4mN4CAchb25dsNRoGr6A38VruaSSAhPco3Rh4AlvKSvXuhgRnzZvNxE/bnHX1D4K5cdTb4FsJg/Ue1l7UcWrlrv1s3H3SwLHP/nf+suD0=,iv:6xBYURjjaQzlUOKOrs2NWOChiNFZVAGPJZQZ59MwX3o=,tag:uXD5VYme+c8eHcCc5TD2YA==,type:str]",
"pgp": [
{
"created_at": "2019-08-29T21:52:32Z",
"enc": "-----BEGIN PGP MESSAGE-----\n\nhQEMAyUpShfNkFB/AQgAlvpTj0NYqF4mQyIeM7wX2SHLb4U07/flpqDpp2W/30Pz\nAHA7sYrgP0l8BrjT2kwtgCN0cdfoIHJudezrNjANp2P5TbP2b9kYYNxpehzB9PFj\nFixnCS7Zp8WIt1yXr1TX+ANZoXLopVcRbMaQ5OdH7CN1pNQtMR+R3FR3X/IqKxiU\nDo1YLaooRJICUC8LJw2Tb4K+lYnTSqd/HalLGym++ivFvdDB1Ya1GhT1FswXidXK\nIRjsOVbxV0q5VeNOR0zxsheOvuHyCje16c7NXJtATJVWtTFABJB8u7CY5HhZSgq+\nrXJHyLHqVLzJ8E4WqHQkMNUlVcrqAz7glZ6xbAhfI9JeAYk5SuBOQOQ4yvASqH4K\nb0N3+/abluBY7YPqKuRZBiEtmcYlZ+zIHuOTP1rD/7L5VY8CwE5U8SFlEqwM7nQJ\n6/vtl6qngOFjwt34WrhZzUfLPB/wRV/m1Qv2kr0RNA==\n=Ykiw\n-----END PGP MESSAGE-----\n",
"fp": "FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4"
}
],
"unencrypted_suffix": "_unencrypted",
"version": "3.6.1"
}
}
}
TASK [Decrypt data] **********************************************************************************************
ok: [localhost]
TASK [Show decrypted data] ***************************************************************************************
ok: [localhost] => {
"msg": {
"dolor": "sit",
"lorem": "ipsum"
}
}
PLAY RECAP *******************************************************************************************************
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Обратите внимание: если поместить выражение Jinja2 в переменную, оно будет вычисляться при каждом использовании. Расшифровка данных занимает некоторое время. Если выражение нужно использовать несколько раз, лучше сначала сохранить его вычисленное значение как факт с помощью ansible.bulitin.set_fact. Это может быть важно, если расшифрованные данные нужно передать роли
---
- name: Decrypt file fetched from URL
hosts: localhost
gather_facts: false
tasks:
- name: Fetch file from URL
ansible.builtin.uri:
url: https://raw.githubusercontent.com/getsops/sops/master/functional-tests/res/comments.enc.yaml
return_content: true
register: encrypted_content
# BAD: every time the role uses decrypted_data, the data will be decrypted!
- name: Call role with decrypted data
include_role:
name: myrole
vars:
role_parameter: "{{ encrypted_content.content | community.sops.decrypt | ansible.builtin.from_yaml }}"
# GOOD: the data is decrypted once before the role is called,
- name: Store decrypted data as fact
set_fact:
decrypted_data: "{{ encrypted_content.content | community.sops.decrypt | ansible.builtin.from_yaml }}"
- name: Call role with decrypted data
include_role:
name: myrole
vars:
role_parameter: "{{ decrypted_data }}"
Работа с зашифрованными переменными
Загружать зашифрованные переменные можно так же, как и с помощью плагина переменных ansible.builtin.host_group_vars, используя плагин переменных community.sops.sops. Если требуется динамически загружать переменные, как с помощью действия ansible.builtin.include_vars, можно использовать действие community.sops.load_vars.
Чтобы использовать плагин переменных, включите его в файл конфигурации Ansible (ansible.cfg):
[defaults] vars_plugins_enabled = host_group_vars,community.sops.sops
Дополнительные сведения о включении плагинов переменных см. в разделе VARIABLE_PLUGINS_ENABLED. Затем можно поместить файлы со следующими расширениями в каталоги group_vars и host_vars:
.sops.yaml.sops.yml.sops.json
(Список расширений можно изменить с помощью valid_extensions.) Плагин переменных расшифрует эти файлы, и вы сможете напрямую использовать их незашифрованное содержимое.
Если нужно динамически загружать зашифрованные переменные, как с помощью встроенного действия ansible.builtin.include_vars, можно использовать действие community.sops.load_vars. Обратите внимание, что оно не является полноценной заменой: встроенное действие использует некоторые специальные случаи, жестко заданные в ansible-core, что позволяет загружать переменные именно как переменные (точнее, как «небезопасные» выражения Jinja2, которые автоматически вычисляются при использовании). Другие плагины действий, например community.sops.load_vars, так делать не могут и должны загружать переменные как факты.
Это имеет значение главным образом при использовании выражений Jinja2 в файле зашифрованных переменных. Когда ansible.builtin.include_vars загружает файл переменных с выражениями, эти выражения вычисляются только тогда, когда требуется вычислить определяющую их переменную (ленивое вычисление). Поскольку community.sops.load_vars возвращает факты, он должен вычислять выражения непосредственно во время загрузки. (Для этого установите параметр expressions в значение evaluate-on-load.) Это особенно важно, если нужно ссылаться на другие переменные из того же файла: это не сработает, поскольку во время вычисления первой переменной Ansible еще не знает об остальных. Ansible «узнает» о них как о фактах только после вычисления всех переменных и завершения действия.
Для следующего примера предположим, что у вас есть зашифрованный файл keys/credentials.sops.yml, который после расшифровки содержит:
encrypted_password: foo
expression: "{{ inventory_hostname }}"
Рассмотрим следующий плейбук:
---
- name: Create SOPS-encrypted private key
hosts: localhost
gather_facts: false
tasks:
- name: Load encrypted credentials
community.sops.load_vars:
file: keys/credentials.sops.yml
expressions: ignore # explicitly do not evaluate expressions
# on load (this is the default)
- name: Show password
debug:
msg: "The password is {{ encrypted_password }}"
- name: Show expression
debug:
msg: "The expression is {{ expression }}"
При запуске будет получен следующий результат:
PLAY [Create SOPS-encrypted private key] *************************************************************************
TASK [Load encrypted credentials] ********************************************************************************
ok: [localhost]
TASK [Show password] *********************************************************************************************
ok: [localhost] => {
"msg": "The password is foo"
}
TASK [Show expression] *******************************************************************************************
ok: [localhost] => {
"msg": "The expression is {{ inventory_hostname }}"
}
PLAY RECAP *******************************************************************************************************
localhost : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Если изменить задачу загрузки переменных следующим образом:
- name: Load encrypted credentials
community.sops.load_vars:
file: keys/credentials.sops.yml
expressions: evaluate-on-load
Теперь последняя задача покажет вычисленное выражение:
TASK [Show expression] *******************************************************************************************
ok: [localhost] => {
"msg": "The expression is localhost"
}
© 2012–2018 Michael DeHaan
© 2018–2025 Red Hat, Inc.
Licensed under the GNU General Public License version 3.
https://docs.ansible.com/projects/ansible/latest/collections/community/sops/docsite/guide.html