From f0b580cd3d0243ddf7077c7584af01e406713609 Mon Sep 17 00:00:00 2001 From: Sandwich Date: Mon, 5 Oct 2026 18:05:05 +0200 Subject: [PATCH] feat(validation): add a strict validate_inputs action for user input --- .gitignore | 1 + ansible.cfg | 1 + plugins/action/validate_inputs.py | 126 +++++++++++++++++++++++++++ tests/inputs/_assert_inputs.yml | 22 +++++ tests/inputs/fixture_spec.yml | 42 +++++++++ tests/inputs/host_vars/localhost.yml | 18 ++++ tests/inputs/inputs_fixtures.yml | 88 +++++++++++++++++++ tests/inputs/test_inputs.yml | 35 ++++++++ 8 files changed, 333 insertions(+) create mode 100644 plugins/action/validate_inputs.py create mode 100644 tests/inputs/_assert_inputs.yml create mode 100644 tests/inputs/fixture_spec.yml create mode 100644 tests/inputs/host_vars/localhost.yml create mode 100644 tests/inputs/inputs_fixtures.yml create mode 100644 tests/inputs/test_inputs.yml diff --git a/.gitignore b/.gitignore index 7fe82e5..64d6134 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ vars_proxmox.yml .sisyphus/ .ansible/ +__pycache__/ diff --git a/ansible.cfg b/ansible.cfg index 788306d..8326fb2 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,6 +1,7 @@ [defaults] hash_behaviour = merge roles_path = roles +action_plugins = plugins/action interpreter_python = auto_silent deprecation_warnings = False host_key_checking = False diff --git a/plugins/action/validate_inputs.py b/plugins/action/validate_inputs.py new file mode 100644 index 0000000..e31f987 --- /dev/null +++ b/plugins/action/validate_inputs.py @@ -0,0 +1,126 @@ +"""Strict validation of user-facing variable dicts against role argument specs. + +Each spec file's ``argument_specs.inputs.options`` declares top-level variables. Every one the host +defines is checked without Ansible's lenient coercion: a string is never a list, bool or dict. A dict +option whose only sub-option is named ```` is a collection keyed by name; each entry is checked +against that sub-option, and a null entry means the entry is not managed. Messages name the key path +and the types involved but never a value, so secrets cannot leak into job output. +""" + +from collections.abc import Mapping + +from ansible.errors import AnsibleError +from ansible.parsing.vault import EncryptedString +from ansible.plugins.action import ActionBase + +KEYED = "" +SPEC_KEYS = {"type", "required", "default", "choices", "elements", "options", "description", "no_log"} +UNRESOLVED = object() + +# Numbers pass where a string is declared and digit strings where an int is: the roles read those +# through `| string` and `| int` unchanged. A string posing as a list, bool or dict never passes. +TYPES = { + "str": lambda v: isinstance(v, (str, int, float, EncryptedString)) and not isinstance(v, bool), + "int": lambda v: (isinstance(v, int) and not isinstance(v, bool)) or (isinstance(v, str) and v.isdigit()), + "float": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), + "bool": lambda v: isinstance(v, bool), + "list": lambda v: isinstance(v, list), + "dict": lambda v: isinstance(v, Mapping), + "raw": lambda _: True, +} + + +def _kind(value): + if value is None: + return "null" + # bool before int: bool is an int subclass. + for name, cls in (("bool", bool), ("dict", Mapping), ("str", (str, EncryptedString)), ("int", int), + ("float", float), ("list", list)): + if isinstance(value, cls): + return name + return type(value).__name__ + + +def _check_spec(options, path): + for key, spec in options.items(): + unknown = set(spec) - SPEC_KEYS + if unknown: + raise AnsibleError(f"{path}.{key}: unsupported spec attributes {', '.join(sorted(unknown))}") + if spec.get("type", "str") not in TYPES: + raise AnsibleError(f"{path}.{key}: unsupported spec type {spec['type']}") + if "options" in spec: + _check_spec(spec["options"], f"{path}.{key}") + + +class _Validator: + def __init__(self, templar): + self.templar = templar + self.errors = [] + + def resolve(self, value): + # Task vars arrive untemplated. Render one node at a time so a vaulted leaf is never decrypted; + # a node that cannot render here (no vault secret, a runtime-only variable) is left unchecked. + if isinstance(value, str) and self.templar.is_template(value): + try: + return self.templar.template(value) + except AnsibleError: + return UNRESOLVED + return value + + def check(self, spec, value, path): + value = self.resolve(value) + if value is UNRESOLVED: + return + kind = spec.get("type", "str") + if not TYPES[kind](value): + self.errors.append(f"{path}: expected {kind}, got {_kind(value)}") + return + if "choices" in spec and value not in spec["choices"]: + shown = "" if spec.get("no_log") else f", got {value!r}" + self.errors.append(f"{path}: must be one of {', '.join(map(str, spec['choices']))}{shown}") + if kind == "dict" and "options" in spec: + self.check_options(spec["options"], value, path) + elif kind == "list" and "elements" in spec: + element = {"type": spec["elements"]} + if "options" in spec: + element["options"] = spec["options"] + for index, item in enumerate(value): + self.check(element, item, f"{path}[{index}]") + + def check_options(self, options, value, path): + if set(options) == {KEYED}: + for name, entry in value.items(): + if entry is not None: + self.check(options[KEYED], entry, f"{path}.{name}") + return + for key in value: + if key not in options: + self.errors.append(f"{path}.{key}: unsupported key, {path} accepts {', '.join(sorted(options))}") + for key, spec in options.items(): + if key in value: + self.check(spec, value[key], f"{path}.{key}") + elif spec.get("required"): + self.errors.append(f"{path}.{key}: required key is missing") + + +class ActionModule(ActionBase): + _requires_connection = False + + def run(self, tmp=None, task_vars=None): + result = super().run(tmp, task_vars) + task_vars = task_vars or {} + validator = _Validator(self._templar) + for path in self._task.args["specs"]: + options = self._loader.load_from_file(path)["argument_specs"]["inputs"]["options"] + _check_spec(options, path) + for name, spec in options.items(): + if name in task_vars: + validator.check(spec, task_vars[name], name) + elif spec.get("required"): + validator.errors.append(f"{name}: required variable is not defined") + if validator.errors: + result.update(failed=True, errors=validator.errors, + msg="Invalid input variables:\n" + "\n".join(validator.errors)) + else: + result.update(changed=False, msg="All input variables are valid") + return result diff --git a/tests/inputs/_assert_inputs.yml b/tests/inputs/_assert_inputs.yml new file mode 100644 index 0000000..189daef --- /dev/null +++ b/tests/inputs/_assert_inputs.yml @@ -0,0 +1,22 @@ +--- +- name: "Validate the fixture ({{ inf.name }})" + block: + - name: "Run the validator ({{ inf.name }})" + validate_inputs: + specs: + - "{{ playbook_dir }}/fixture_spec.yml" + vars: + fixture: "{{ inf.value }}" + register: inputs_result + rescue: + - name: "Keep the failed result ({{ inf.name }})" + ansible.builtin.set_fact: + inputs_result: "{{ ansible_failed_result }}" + +- name: "Assert the reported violations ({{ inf.name }})" + ansible.builtin.assert: + that: + - (inputs_result.errors | default([])) | sort == inf.expect | sort + - "'topsecret-value' not in (inputs_result | to_json)" + fail_msg: "[inputs {{ inf.name }}] expected {{ inf.expect }}, got {{ inputs_result.errors | default([]) }}" + success_msg: "[inputs {{ inf.name }}] OK" diff --git a/tests/inputs/fixture_spec.yml b/tests/inputs/fixture_spec.yml new file mode 100644 index 0000000..9c83490 --- /dev/null +++ b/tests/inputs/fixture_spec.yml @@ -0,0 +1,42 @@ +--- +argument_specs: + inputs: + options: + fixture: + type: dict + options: + name: + type: str + required: true + enabled: + type: bool + count: + type: int + servers: + type: list + elements: str + kind: + type: str + choices: [a, b] + secret: + type: str + no_log: true + disks: + type: list + elements: dict + options: + size: + type: int + required: true + users: + type: dict + options: + "": + type: dict + options: + password: + type: str + no_log: true + keys: + type: list + elements: str diff --git a/tests/inputs/host_vars/localhost.yml b/tests/inputs/host_vars/localhost.yml new file mode 100644 index 0000000..b7f767d --- /dev/null +++ b/tests/inputs/host_vars/localhost.yml @@ -0,0 +1,18 @@ +--- +# Vaulted with a discarded password: the validator must handle a value it cannot decrypt. +fixture: + name: web01 + secret: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 62363835313830363035333361613236373662303039376639396136336438616331336565393631 + 6637376163346430386663393661386561316537306662350a663563326164376164396230616361 + 33646564346532643735396239626133333162353839666135343030643836313938363466383439 + 3130353636666365310a353064373139323836373163336563353861356333316534386238333664 + 3663 + servers: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 65626131373433633432363261356534613135386461626535393539656631323531346661643233 + 3961366535643630386462303038636231373239343130310a343565336461633532316136643139 + 30306461616232663763633630353661376130323536346637633964373239343664303533396236 + 6165623262633131330a303965643439626335663130376237393362363231346537643334633163 + 3835 diff --git a/tests/inputs/inputs_fixtures.yml b/tests/inputs/inputs_fixtures.yml new file mode 100644 index 0000000..a3198e6 --- /dev/null +++ b/tests/inputs/inputs_fixtures.yml @@ -0,0 +1,88 @@ +--- +inputs_fixtures: + - name: valid + value: + name: web01 + enabled: true + count: "3" + servers: [10.0.0.1, 10.0.0.2] + kind: a + disks: + - size: 30 + users: + alice: + password: pw + keys: [ssh-ed25519 AAAA alice] + retired: ~ + expect: [] + + - name: list-for-dict + value: + name: web01 + users: + - name: alice + expect: ["fixture.users: expected dict, got list"] + + - name: comma-string-for-list + value: + name: web01 + servers: 10.0.0.1,10.0.0.2 + expect: ["fixture.servers: expected list, got str"] + + - name: yes-string-for-bool + value: + name: web01 + enabled: "yes" + expect: ["fixture.enabled: expected bool, got str"] + + - name: typo-key + value: + name: web01 + userz: {} + expect: ["fixture.userz: unsupported key, fixture accepts count, disks, enabled, kind, name, secret, servers, users"] + + - name: wrong-element-type + value: + name: web01 + disks: + - size: 30 + - size: big + expect: ["fixture.disks[1].size: expected int, got str"] + + - name: missing-required + value: + enabled: false + expect: ["fixture.name: required key is missing"] + + - name: choice-mismatch + value: + name: web01 + kind: c + expect: ["fixture.kind: must be one of a, b, got 'c'"] + + - name: secrets-never-echoed + value: + name: web01 + secret: [topsecret-value] + users: + alice: + password: [topsecret-value] + expect: + - "fixture.secret: expected str, got list" + - "fixture.users.alice.password: expected str, got list" + + - name: keyed-entry-not-a-dict + value: + name: web01 + users: + alice: pw + expect: ["fixture.users.alice: expected dict, got str"] + + - name: two-violations-at-once + value: + name: web01 + enabled: "true" + userz: {} + expect: + - "fixture.userz: unsupported key, fixture accepts count, disks, enabled, kind, name, secret, servers, users" + - "fixture.enabled: expected bool, got str" diff --git a/tests/inputs/test_inputs.yml b/tests/inputs/test_inputs.yml new file mode 100644 index 0000000..384ebfd --- /dev/null +++ b/tests/inputs/test_inputs.yml @@ -0,0 +1,35 @@ +--- +# Run: ansible-playbook tests/inputs/test_inputs.yml +- name: Strict input validator fixture tests + hosts: localhost + gather_facts: false + connection: local + vars_files: + - inputs_fixtures.yml + tasks: + - name: Run each input fixture + ansible.builtin.include_tasks: _assert_inputs.yml + loop: "{{ inputs_fixtures }}" + loop_control: + loop_var: inf + label: "{{ inf.name }}" + + - name: Validate a host var holding values that cannot be decrypted + block: + - name: Run the validator on the vaulted host var + validate_inputs: + specs: + - "{{ playbook_dir }}/fixture_spec.yml" + register: inputs_vault_result + rescue: + - name: Keep the failed vault result + ansible.builtin.set_fact: + inputs_vault_result: "{{ ansible_failed_result }}" + + - name: Assert a vaulted string passes and a vaulted non-list is reported + ansible.builtin.assert: + that: + - >- + inputs_vault_result.errors | default([]) == ['fixture.servers: expected list, got str'] + fail_msg: "[inputs vault] got {{ inputs_vault_result.errors | default([]) }}" + success_msg: "[inputs vault] OK"