Skip to content

Commit ccca49f

Browse files
committed
feat: honor ANSIBLE_VAULT_PASSWORD_FILE for vault decryption
Delegate vault secret initialization to Ansible's own CLI.setup_vault_secrets(), which reads: - ANSIBLE_VAULT_PASSWORD_FILE environment variable - vault_password_file in ansible.cfg - ANSIBLE_VAULT_IDENTITY_LIST / vault_identity_list Previously, all DataLoader instances were initialized with a hardcoded dummy vault password "x", causing decryption failures for fully encrypted vault files (e.g., group_vars/*/vault.yaml). This meant JinjaRule and VariableNamingRule silently skipped vault files. The fix centralizes vault secret loading in _get_vault_secrets() which caches the result and falls back to the dummy password when no vault configuration is found. All three DataLoader creation sites (parse_yaml_from_file, path_dwim, ansible_templar) now use the shared _make_dataloader() factory. Interactive prompts are disabled (ask_vault_pass=False, auto_prompt=False). Fixes #2889, #2506, #2443, #3718 Supersedes #4069 Signed-off-by: John Lahr <john@johnlahr.me>
1 parent 5fac056 commit ccca49f

4 files changed

Lines changed: 143 additions & 14 deletions

File tree

docs/usage.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,10 +287,17 @@ follows:
287287

288288
## Vaults
289289

290-
As ansible-lint executes ansible, it also needs access to encrypted secrets. If
291-
you do not give access to them or you are concerned about security implications,
292-
you should consider refactoring your code to allow it to be linted without
293-
access to real secrets:
290+
Ansible-lint honors the same vault configuration sources that `ansible-playbook`
291+
uses, so it can decrypt vault-encrypted files during linting:
292+
293+
- `ANSIBLE_VAULT_PASSWORD_FILE` environment variable
294+
- `vault_password_file` in `ansible.cfg`
295+
- `ANSIBLE_VAULT_IDENTITY_LIST` / `vault_identity_list`
296+
297+
Interactive prompts (`--ask-vault-pass`) are not supported.
298+
299+
If you do not give access to vault secrets or you are concerned about security
300+
implications, you can lint without access to real secrets:
294301

295302
- Configure dummy fallback values that are used during linting, so Ansible will
296303
not complain about undefined variables.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
$ANSIBLE_VAULT;1.1;AES256
2+
66386639366132393765316562616337613062313164336432346166346534386264306262373838
3+
6661313433613237653261323861323231393662343666350a366439633762613134666432623732
4+
38656632346532333339303365323863373939663036613038363339653730653136326161303134
5+
3464353961303161380a306463393935646234643663373065633936303635663530376130363130
6+
61323530386466643865333530353231333838323564653333643736353636363633

src/ansiblelint/utils.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
to_bytes,
5656
)
5757

58+
from ansible import constants as C
59+
from ansible.cli import CLI
5860
from ansible.module_utils.parsing.convert_bool import boolean
5961
from ansible.parsing.dataloader import DataLoader
6062
from ansible.parsing.mod_args import ModuleArgsParser
@@ -106,9 +108,7 @@
106108
if TYPE_CHECKING:
107109
from ansiblelint.app import App
108110
from ansiblelint.rules import RulesCollection
109-
# ansible-lint doesn't need/want to know about encrypted secrets, so we pass a
110-
# string as the password to enable such yaml files to be opened and parsed
111-
# successfully.
111+
# Fallback vault password used when no vault configuration is found.
112112
DEFAULT_VAULT_PASSWORD = "x" # noqa: S105
113113

114114
PLAYBOOK_DIR = os.environ.get("ANSIBLE_PLAYBOOK_DIR", None)
@@ -119,14 +119,63 @@
119119

120120
_logger = logging.getLogger(__name__)
121121

122+
# Cached vault secrets, initialized on first use.
123+
_vault_secrets: list[tuple[str, Any]] | None = None
124+
125+
126+
def _get_vault_secrets() -> list[tuple[str, Any]]:
127+
"""Return vault secrets from Ansible configuration, falling back to a dummy password."""
128+
# pylint: disable=global-statement
129+
global _vault_secrets
130+
if _vault_secrets is not None:
131+
return _vault_secrets
132+
133+
loader = DataLoader() # type: ignore[no-untyped-call,unused-ignore]
134+
135+
vault_ids = (
136+
list(C.DEFAULT_VAULT_IDENTITY_LIST)
137+
if C.DEFAULT_VAULT_IDENTITY_LIST
138+
else []
139+
)
140+
141+
try:
142+
secrets = CLI.setup_vault_secrets(
143+
loader,
144+
vault_ids=vault_ids,
145+
ask_vault_pass=False,
146+
auto_prompt=False,
147+
initialize_context=False,
148+
)
149+
if secrets:
150+
_vault_secrets = secrets
151+
return _vault_secrets
152+
except (AnsibleError, RuntimeError):
153+
_logger.debug(
154+
"Failed to load vault secrets from configuration",
155+
exc_info=True,
156+
)
157+
158+
# Fall back to dummy password for backward compatibility
159+
_vault_secrets = [
160+
(
161+
"default",
162+
PromptVaultSecret(_bytes=to_bytes(DEFAULT_VAULT_PASSWORD)), # type: ignore[no-untyped-call]
163+
),
164+
]
165+
return _vault_secrets
166+
167+
168+
def _make_dataloader() -> DataLoader:
169+
"""Create a DataLoader with vault secrets from Ansible configuration."""
170+
loader = DataLoader() # type: ignore[no-untyped-call,unused-ignore]
171+
if hasattr(loader, "set_vault_secrets"):
172+
loader.set_vault_secrets(_get_vault_secrets())
173+
return loader
174+
122175

123176
def parse_yaml_from_file(filepath: str) -> AnsibleJSON:
124177
"""Extract a decrypted YAML object from file."""
125-
dataloader = DataLoader() # type: ignore[no-untyped-call,unused-ignore]
126-
if hasattr(dataloader, "set_vault_secrets"):
127-
dataloader.set_vault_secrets([
128-
("default", PromptVaultSecret(_bytes=to_bytes(DEFAULT_VAULT_PASSWORD))) # type: ignore[no-untyped-call]
129-
])
178+
dataloader = _make_dataloader()
130179
result: object = dataloader.load_from_file(filepath)
131180
if result is None:
132181
return result
@@ -139,7 +188,7 @@ def parse_yaml_from_file(filepath: str) -> AnsibleJSON:
139188

140189
def path_dwim(basedir: str, given: str) -> str:
141190
"""Convert a given path do-what-I-mean style."""
142-
dataloader = DataLoader() # type: ignore[no-untyped-call,unused-ignore]
191+
dataloader = _make_dataloader()
143192
dataloader.set_basedir(basedir)
144193
return str(dataloader.path_dwim(given))
145194

@@ -154,7 +203,7 @@ def ansible_templar(basedir: Path, templatevars: Any) -> Templar:
154203
if basedir.name == "tasks":
155204
basedir = basedir.parent
156205

157-
dataloader = DataLoader() # type: ignore[no-untyped-call,unused-ignore]
206+
dataloader = _make_dataloader()
158207
dataloader.set_basedir(str(basedir))
159208
templar = Templar(dataloader, variables=templatevars)
160209
return templar

test/test_vault.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Tests for vault secret initialization."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest.mock import patch
7+
8+
import pytest
9+
10+
from ansible import constants as C
11+
from ansible.errors import AnsibleError
12+
13+
from ansiblelint import utils
14+
15+
16+
VAULT_PASS_FILE = str(Path(__file__).parent.parent / ".vault_pass")
17+
VAULT_ENCRYPTED_FILE = str(
18+
Path(__file__).parent.parent / "examples/playbooks/vars/vault_encrypted.yml"
19+
)
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def _reset_vault_cache() -> None:
24+
"""Reset the cached vault secrets between tests."""
25+
utils._vault_secrets = None # noqa: SLF001
26+
27+
28+
def test_vault_secrets_loaded() -> None:
29+
"""Vault secrets are loaded from ansible configuration."""
30+
secrets = utils._get_vault_secrets() # noqa: SLF001
31+
assert len(secrets) >= 1
32+
_vault_id, secret = secrets[0]
33+
# Should be the real password from .vault_pass, not the dummy
34+
assert secret.bytes != b"x"
35+
36+
37+
def test_vault_secrets_fallback() -> None:
38+
"""Dummy password is returned when no vault configuration exists."""
39+
with patch.object(C, "DEFAULT_VAULT_PASSWORD_FILE", None):
40+
secrets = utils._get_vault_secrets() # noqa: SLF001
41+
assert len(secrets) == 1
42+
_vault_id, secret = secrets[0]
43+
assert secret.bytes == b"x"
44+
45+
46+
def test_vault_secrets_bad_file() -> None:
47+
"""Graceful fallback when vault password file does not exist."""
48+
with patch.object(C, "DEFAULT_VAULT_PASSWORD_FILE", "/nonexistent/vault_pass"):
49+
secrets = utils._get_vault_secrets() # noqa: SLF001
50+
assert len(secrets) == 1
51+
_vault_id, secret = secrets[0]
52+
assert secret.bytes == b"x"
53+
54+
55+
def test_vault_decrypt_with_password() -> None:
56+
"""Vault-encrypted files are decrypted when password is available."""
57+
result = utils.parse_yaml_from_file(VAULT_ENCRYPTED_FILE)
58+
assert result is not None
59+
assert isinstance(result, dict)
60+
assert result["my_secret"] == "test_value"
61+
62+
63+
def test_vault_decrypt_without_password() -> None:
64+
"""Vault-encrypted files cannot be decrypted with the dummy password."""
65+
with patch.object(C, "DEFAULT_VAULT_PASSWORD_FILE", None):
66+
with pytest.raises(AnsibleError, match="Decryption failed"):
67+
utils.parse_yaml_from_file(VAULT_ENCRYPTED_FILE)

0 commit comments

Comments
 (0)