diff --git a/.github/actions/set-up-legacy-python/action.yml b/.github/actions/set-up-legacy-python/action.yml new file mode 100644 index 00000000..720a49ab --- /dev/null +++ b/.github/actions/set-up-legacy-python/action.yml @@ -0,0 +1,30 @@ +name: "Set up legacy Python" +description: "Sets up a specified Python version in a virtual environment using `pyenv`, installs dependencies, and caches the environment" +inputs: + python-version: + required: true + description: "The Python version to set up" + +runs: + using: "composite" + steps: + - uses: actions/cache@v4 + id: pyenv-cache + with: + path: | + /opt/hostedtoolcache/pyenv_root/2.4.20/x64/versions/${{ inputs.python-version }} + key: ${{ inputs.python-version }}-${{ hashFiles('requirements.txt') }} + - name: Set up Python ${{ inputs.python-version }} using pyenv + uses: gabrielfalcao/pyenv-action@32ef4d2c861170ce17ded56d10329d83f4c8f797 + if: steps.pyenv-cache.outputs.cache-hit != 'true' + with: + default: "${{ inputs.python-version }}" + command: pip install -U pip + - name: Add Python ${{ inputs.python-version }} to PATH + run: echo "/opt/hostedtoolcache/pyenv_root/2.4.20/x64/versions/${{ inputs.python-version }}/bin" >> $GITHUB_PATH + shell: bash + - name: Install dependencies + run: | + python -m pip install flake8 pytest setuptools wheel + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..23d75c23 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: Continuous Integration + +on: + push: + branches: [master] + pull_request: + types: [opened, reopened, synchronize] + +jobs: + lint_and_test: + name: Lint and test + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Install CFEngine community for tests + run: | + pipx install cf-remote + cf-remote install --clients localhost --edition community + - name: Run make check for linting, formatting and tests + run: make check diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 00000000..aa3d61e3 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,26 @@ +name: Python tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + test-legacy: + runs-on: ubuntu-24.04 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: ["3.5.10", "3.6.15", "3.7.10", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up legacy Python ${{ matrix.python-version }} + uses: ./.github/actions/set-up-legacy-python + with: + python-version: ${{ matrix.python-version }} + - name: Test cfengine_module_library is importable + run: python -c "import sys; sys.path.insert(0, 'libraries/python'); import cfengine_module_library; print('OK', sys.version)" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..326b2e5f --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,24 @@ +# For more information, see: +# https://github.com/actions/stale +name: Label and close stale pull requests + +on: + schedule: + - cron: "31 19 * * *" + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - uses: actions/stale@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-pr-message: "Marking this PR as stale due to inactivity; it will be closed in 7 days." + stale-pr-label: "stale" + days-before-stale: 30 + days-before-close: 7 + close-pr-message: "Closing PR due to inactivity. Feel free to open a new PR if necessary." diff --git a/.gitignore b/.gitignore index e5e89d59..8bfd5e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ node_modules/ build/ dist/ .cache +tests/deploy/cfbs.json +tests/deploy/out diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..69f1f719 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: check lint format all clean tools + +all: tools clean lint format check + +tools: + sudo pipx install --global flake8 pyright black pyflakes pytest cfbs cfengine + echo "deploy test requires cf-agent installed..." + command -v cf-agent + +clean: + rm -rf tests/deploy/out + +lint: clean tools + cfbs status + cfbs validate + cfbs --check pretty ./cfbs.json + ./ci/linting.sh + cfengine lint --strict no ./ + +format: lint + cfengine format --check + +check: format + pytest promise-types/ -v + bash tests/deploy/test.sh diff --git a/cfbs.json b/cfbs.json index 8f35b92e..372655b5 100644 --- a/cfbs.json +++ b/cfbs.json @@ -1,24 +1,208 @@ { "name": "modules", "description": "Repository of modules written by the CFEngine team.", - "type": "modules", + "type": "module", "provides": { + "allow-all-hosts": { + "description": "Allows all hosts / IP addresses to connect and fetch policy.", + "subdirectory": "management/allow-all-hosts", + "steps": ["json def.json def.json"] + }, "autorun": { "description": "Enable autorun functionality.", "subdirectory": "management/autorun", "steps": ["json def.json def.json"] }, + "autorun-bundles": { + "description": "Enable automatic execution of bundles tagged 'autorun'.", + "subdirectory": "management/autorun-bundles", + "steps": ["json def.json def.json"] + }, + "autorun-inputs": { + "description": "Enable automatic loading of policy files in 'services/autorun/'.", + "subdirectory": "management/autorun-inputs", + "steps": ["json def.json def.json"] + }, + "disable-recommendations": { + "description": "Disable all recommendations emitted from the Masterfiles Policy Framework (MPF).", + "subdirectory": "management/disable-recommendations", + "steps": ["json def.json def.json"] + }, "client-initiated-reporting": { "description": "Enable client initiated reporting and disable pull collection.", "subdirectory": "reporting/client-initiated-reporting", "steps": ["json def.json def.json"] }, + "compliance-report-fwupd": { + "description": "Compliance report for firmware security posture via fwupd HSI and update status.", + "tags": ["reporting", "compliance", "security", "hardware"], + "subdirectory": "reporting/compliance-report-fwupd", + "dependencies": ["inventory-fwupd"], + "steps": [ + "copy fwupd-compliance-report.json .no-distrib/compliance-report-definitions/fwupd-compliance-report.json" + ] + }, + "command-dispatcher": { + "description": "Command dispatcher for running shell commands on schedule", + "subdirectory": "management/command-dispatcher", + "steps": [ + "copy main.cf services/cfbs/modules/command-dispatcher/main.cf", + "policy_files services/cfbs/modules/command-dispatcher/main.cf", + "bundles command_dispatcher:main", + "input ./input.json def.json" + ], + "input": [ + { + "type": "list", + "variable": "commands_to_run", + "namespace": "command_dispatcher", + "bundle": "main", + "label": "Command", + "subtype": [ + { + "key": "command", + "type": "string", + "label": "Command", + "question": "Command to run" + }, + { + "key": "condition", + "type": "string", + "label": "Condition", + "question": "Condition for when to run", + "default": "any" + }, + { + "key": "ifelapsed", + "type": "string", + "label": "ifelapsed", + "question": "Number of minutes between promise assessments", + "default": "5" + } + ], + "while": "Do you want to specify more commands to be run?" + } + ] + }, + "conditional-installer": { + "description": "Allows you to specify packages you want installed and conditions for where you want them installed, as well as a list of packages you generally want uninstalled.", + "subdirectory": "security/conditional-installer", + "steps": [ + "copy main.cf services/cfbs/modules/conditional-installer/main.cf", + "input ./input.json def.json", + "bundles conditional_installer:main", + "policy_files services/cfbs/modules/conditional-installer/main.cf" + ], + "input": [ + { + "type": "string", + "variable": "packages_to_uninstall", + "namespace": "conditional_installer", + "bundle": "main", + "label": "Uninstall", + "question": "Which package(s) would you like to be uninstalled?" + }, + { + "type": "list", + "variable": "packages_to_install", + "namespace": "conditional_installer", + "bundle": "main", + "label": "Install", + "subtype": [ + { + "key": "packages", + "type": "string", + "label": "Package(s)", + "question": "Package(s) to install" + }, + { + "key": "condition", + "type": "string", + "label": "Condition", + "question": "Condition for where to install" + }, + { + "key": "why", + "type": "string", + "label": "Why", + "question": "Why?", + "default": "Unknown" + } + ], + "while": "Do you want to specify more packages to be installed?" + } + ] + }, + "delete-home-dotrhosts": { + "description": "Ensure that ~/.rhosts files are not present, as they present a security risk.", + "subdirectory": "security/delete-home-dotrhosts", + "steps": [ + "copy policy/main.cf services/cfbs/delete-home-dotrhosts/", + "policy_files services/cfbs/delete-home-dotrhosts/", + "bundles delete_home_dotrhosts:main" + ] + }, + "delete-home-dotshosts": { + "description": "Ensure that ~/.shosts files are not present, as they present a security risk.", + "subdirectory": "security/delete-home-dotshosts", + "steps": [ + "copy policy/main.cf services/cfbs/delete-home-dotshots/", + "policy_files services/cfbs/delete-home-dotshots/", + "bundles delete_home_dotshosts:main" + ] + }, + "dirtyfrag": { + "description": "Detect and optionally mitigate CVE-2026-43284 (DirtyFrag) and CVE-2026-43500 in the Linux kernel.", + "tags": ["security", "inventory", "detection", "mitigation"], + "subdirectory": "security/dirtyfrag", + "steps": [ + "copy dirtyfrag.cf services/cfbs/modules/dirtyfrag/dirtyfrag.cf", + "copy patched-kernels.json services/cfbs/modules/dirtyfrag/patched-kernels.json", + "policy_files services/cfbs/modules/dirtyfrag/dirtyfrag.cf", + "bundles dirtyfrag:main", + "input ./input.json def.json" + ], + "input": [ + { + "type": "string", + "variable": "mitigate_esp", + "namespace": "dirtyfrag", + "bundle": "main", + "label": "Mitigate CVE-2026-43284 (ESP/IPComp)", + "question": "Blacklist esp4, esp6, ipcomp, ipcomp6 kernel modules? (breaks IPsec) [true/false]", + "default": "false" + }, + { + "type": "string", + "variable": "mitigate_rxrpc", + "namespace": "dirtyfrag", + "bundle": "main", + "label": "Mitigate CVE-2026-43500 (RxRPC)", + "question": "Blacklist rxrpc kernel module? (breaks AFS/RxRPC) [true/false]", + "default": "false" + }, + { + "type": "string", + "variable": "mitigate_userns", + "namespace": "dirtyfrag", + "bundle": "main", + "label": "Mitigate CVE-2026-43284 via user namespaces", + "question": "Set user.max_user_namespaces=0? (blocks ESP exploit without disabling IPsec, may break rootless containers) [true/false]", + "default": "false" + } + ] + }, "demo": { "description": "Enables convenient and insecure settings for demoing CFEngine.", "subdirectory": "management/demo", "dependencies": ["autorun", "every-minute"], "steps": ["json def.json def.json"] }, + "disable-automatic-key-trust": { + "description": "Makes the hub / policy server stop accepting new keys automatically.", + "subdirectory": "management/disable-automatic-key-trust", + "steps": ["json def.json def.json"] + }, "enable-aslr": { "description": "Ensure that Address space layout randomization (ASLR) is enabled on the system.", "subdirectory": "security/enable-aslr", @@ -33,6 +217,65 @@ "subdirectory": "management/every-minute", "steps": ["json def.json def.json"] }, + "inventory-selinux-modules": { + "description": "Inventory installed SELinux modules and their enabled/disabled status.", + "subdirectory": "security/inventory-selinux-modules", + "steps": [ + "copy main.cf services/cfbs/modules/inventory-selinux-modules/main.cf", + "policy_files services/cfbs/modules/inventory-selinux-modules/main.cf", + "bundles inventory_selinux:semodule_list_modules" + ] + }, + "inventory-unshadowed-users": { + "description": "Adds reporting data (inventory) on local users in /etc/passwd not using /etc/shadow for their password.", + "subdirectory": "security/inventory-unshadowed-users", + "dependencies": ["library-parsed-local-users"], + "steps": [ + "copy policy/main.cf services/cfbs/inventory-unshadowed-users/", + "policy_files services/cfbs/inventory-unshadowed-users/", + "bundles inventory_unshadowed_users:main" + ] + }, + "inventory-windows-services": { + "description": "Inventory running Windows services.", + "subdirectory": "inventory/inventory-windows-services", + "steps": [ + "copy inventory-windows-services.cf services/cfbs/inventory-windows-services/", + "policy_files services/cfbs/inventory-windows-services/", + "bundles inventory_windows_services_running" + ] + }, + "inventory-fde": { + "description": "Inventory full disk encryption status (LUKS, FileVault, BitLocker).", + "tags": ["inventory", "security"], + "subdirectory": "inventory/inventory-fde", + "steps": [ + "copy inventory-fde.cf services/cfbs/modules/inventory-fde/inventory-fde.cf", + "policy_files services/cfbs/modules/inventory-fde/inventory-fde.cf", + "bundles inventory_fde:main" + ] + }, + "inventory-fwupd": { + "description": "Inventory hardware firmware versions, pending firmware updates, and host security attributes via fwupd.", + "tags": ["inventory", "monitoring", "hardware", "security"], + "subdirectory": "inventory/inventory-fwupd", + "steps": [ + "copy policy.cf services/cfbs/modules/inventory-fwupd/policy.cf", + "copy fwupd-inventory.mustache services/cfbs/modules/inventory-fwupd/fwupd-inventory.mustache", + "policy_files services/cfbs/modules/inventory-fwupd/policy.cf", + "bundles inventory_fwupd_main" + ] + }, + "inventory-smartctl": { + "description": "Inventory SMART drive health, temperature, and wear data.", + "tags": ["inventory", "monitoring", "hardware", "storage"], + "subdirectory": "inventory/inventory-smartctl", + "steps": [ + "copy policy.cf services/cfbs/modules/inventory-smartctl/policy.cf", + "policy_files services/cfbs/modules/inventory-smartctl/policy.cf", + "bundles inventory_smartctl:main" + ] + }, "library-for-promise-types-in-bash": { "description": "Library enabling promise types implemented in bash.", "subdirectory": "libraries/bash", @@ -41,7 +284,10 @@ "library-for-promise-types-in-python": { "description": "Library enabling promise types implemented in python.", "subdirectory": "libraries/python", - "steps": ["copy cfengine.py modules/promises/"] + "steps": [ + "copy cfengine_module_library.py modules/promises/cfengine_module_library.py", + "copy cfengine_module_library.py modules/promises/cfengine.py" + ] }, "maintainers-in-motd": { "description": "Add maintainer and purpose information from CMDB to /etc/motd", @@ -52,19 +298,169 @@ "bundles maintainers_in_motd" ] }, + "manage-fwupd": { + "description": "Ensure fwupd is installed and its refresh timer is enabled. Optionally apply firmware updates for devices matching an allow-list.", + "subdirectory": "management/manage-fwupd", + "dependencies": ["inventory-fwupd"], + "steps": [ + "copy main.cf services/cfbs/modules/manage-fwupd/main.cf", + "policy_files services/cfbs/modules/manage-fwupd/main.cf", + "bundles manage_fwupd:allowed manage_fwupd:main", + "input ./input.json def.json" + ], + "input": [ + { + "type": "string", + "variable": "apply_updates", + "namespace": "manage_fwupd", + "bundle": "allowed", + "label": "Apply firmware updates", + "question": "Class expression for when firmware updates should be applied (e.g. 'any', 'linux', '(env_dev|env_qa).Night.(cohort_A|cohort_C)'). Default '!any' means disabled.", + "default": "!any" + }, + { + "type": "list", + "variable": "device_name_reglist", + "namespace": "manage_fwupd", + "bundle": "allowed", + "label": "Allowed devices", + "subtype": { + "type": "string", + "label": "Device name pattern", + "question": "Device name (regex) to allow firmware updates for" + }, + "while": "Do you want to allow firmware updates for more devices?" + }, + { + "type": "string", + "variable": "reboot_after_update", + "namespace": "manage_fwupd", + "bundle": "allowed", + "label": "Reboot after firmware update", + "question": "Class expression for when to reboot after a firmware update is applied (e.g. 'any', 'Night', 'Hr04.Min00_05'). Default '!any' means disabled (no automatic reboot).", + "default": "!any" + } + ] + }, + "powershell-execution-policy": { + "description": "Inventory and bundle for PowerShell Execution Policy", + "subdirectory": "management/powershell-execution-policy", + "steps": [ + "directory ./ services/cfbs/powershell-execution-policy/", + "policy_files services/cfbs/powershell-execution-policy/", + "bundles powershell_execution_policy_inventory" + ] + }, + "package-method-ansible-galaxy-collection": { + "description": "Package method for installing Ansible Galaxy Collections.", + "subdirectory": "management/package-method-ansible-galaxy-collection", + "dependencies": [], + "steps": [ + "directory ./ services/cfbs/modules/package-method-ansible-galaxy-collection/", + "policy_files services/cfbs/modules/package-method-ansible-galaxy-collection/" + ] + }, + "package-method-winget": { + "description": "Package method for Windows winget package manager.", + "subdirectory": "management/package-method-winget", + "dependencies": ["powershell-execution-policy"], + "steps": [ + "input ./input.json def.json", + "directory ./ services/cfbs/modules/package-method-winget/", + "policy_files services/cfbs/modules/package-method-winget/", + "bundles package_method_winget:package_method_winget winget_installed:winget_installed" + ], + "input": [ + { + "type": "string", + "variable": "accept_source_agreements", + "namespace": "data", + "bundle": "package_method_winget", + "label": "Accept Source Agreements", + "question": "Would you like to accept source agreements for winget packages promises? [yes|no]" + }, + { + "type": "string", + "variable": "accept_package_agreements", + "namespace": "data", + "bundle": "package_method_winget", + "label": "Accept Package Agreements", + "question": "Would you like to accept package agreements for winget packages promises? [yes|no]" + }, + { + "type": "string", + "variable": "allow_powershell_execution_policy_change", + "namespace": "data", + "bundle": "winget_installed", + "label": "Allow necessary PowerShell Execution Policy change: LocalMachine set to Unrestricted in order to install winget and cmdlets", + "question": "Would you like to allow this module to change PowerShell Execution Policy to LocalMachine:Unrestricted? [yes|no]" + } + ] + }, + "policy-dispatcher": { + "description": "Policy dispatcher for saving policy snippets to files and running them with cf-agent", + "subdirectory": "management/policy-dispatcher", + "steps": [ + "copy main.cf services/cfbs/modules/policy-dispatcher/main.cf", + "policy_files services/cfbs/modules/policy-dispatcher/main.cf", + "bundles policy_dispatcher:main", + "input ./input.json def.json" + ], + "input": [ + { + "type": "list", + "variable": "policy_files_to_run", + "namespace": "policy_dispatcher", + "bundle": "main", + "label": "Policy", + "subtype": [ + { + "key": "policy", + "type": "string", + "label": "Policy", + "question": "Policy snippet to save and run (must contain a 'main' bundle)" + }, + { + "key": "condition", + "type": "string", + "label": "Condition", + "question": "Condition for when to run", + "default": "any" + }, + { + "key": "ifelapsed", + "type": "string", + "label": "ifelapsed", + "question": "Number of minutes between promise assessments", + "default": "5" + } + ], + "while": "Do you want to specify more policy snippets to be run?" + } + ] + }, "promise-type-ansible": { "description": "Promise type to manage systemd services.", - "dependencies": ["library-for-promise-types-in-python"], "subdirectory": "promise-types/ansible", + "dependencies": ["library-for-promise-types-in-python"], "steps": [ "copy ansible_promise.py modules/promises/", "append enable.cf services/init.cf" ] }, + "promise-type-appstreams": { + "description": "Promise type to manage AppStream modules.", + "subdirectory": "promise-types/appstreams", + "dependencies": ["library-for-promise-types-in-python"], + "steps": [ + "copy appstreams.py modules/promises/", + "append init.cf services/init.cf" + ] + }, "promise-type-git": { "description": "Promise type to manage git repos.", - "dependencies": ["library-for-promise-types-in-python"], "subdirectory": "promise-types/git", + "dependencies": ["library-for-promise-types-in-python"], "steps": [ "copy git.py modules/promises/", "append enable.cf services/init.cf" @@ -72,17 +468,26 @@ }, "promise-type-groups": { "description": "Experimental promise type to manage local user groups.", - "dependencies": ["library-for-promise-types-in-python"], "subdirectory": "promise-types/groups", + "dependencies": ["library-for-promise-types-in-python"], "steps": [ "copy groups.py modules/promises/", "append enable.cf services/init.cf" ] }, + "promise-type-sshd": { + "description": "Promise type to configure sshd.", + "subdirectory": "promise-types/sshd", + "dependencies": ["library-for-promise-types-in-python"], + "steps": [ + "copy sshd_promise_type.py modules/promises/", + "append enable.cf services/init.cf" + ] + }, "promise-type-systemd": { "description": "Promise type to manage systemd services.", - "dependencies": ["library-for-promise-types-in-python"], "subdirectory": "promise-types/systemd", + "dependencies": ["library-for-promise-types-in-python"], "steps": [ "copy systemd.py modules/promises/", "append enable.cf services/init.cf" @@ -91,6 +496,12 @@ "uninstall-packages": { "description": "Allows you to specify a list of packages you want uninstalled on your hosts.", "subdirectory": "security/uninstall-packages", + "steps": [ + "copy main.cf services/cfbs/modules/uninstall-packages/main.cf", + "input uninstall-packages/input.json def.json", + "bundles uninstall_packages:uninstall_packages", + "policy_files services/cfbs/modules/uninstall-packages/main.cf" + ], "input": [ { "type": "list", @@ -106,21 +517,15 @@ "question": "Package name" }, { - "key": "why", - "type": "string", - "label": "Why", - "question": "Why should this package be uninstalled?", - "default": "Unknown" + "key": "why", + "type": "string", + "label": "Why", + "question": "Why should this package be uninstalled?", + "default": "Unknown" } ], "while": "Do you want to specify more packages to be uninstalled?" } - ], - "steps": [ - "copy uninstall-packages.cf services/cfbs/modules/uninstall-packages/uninstall-packages.cf", - "input uninstall-packages/input.json def.json", - "bundles uninstall_packages:uninstall_packages", - "policy_files services/cfbs/modules/uninstall-packages/uninstall-packages.cf" ] }, "uninstall-rsh-server": { @@ -132,32 +537,40 @@ "policy_files services/cfbs/modules/uninstall-rsh-server/uninstall-rsh-server.cf" ] }, - "inventory-unshadowed-users": { - "description": "Adds reporting data (inventory) on local users in /etc/passwd not using /etc/shadow for their password.", - "dependencies": ["library-parsed-local-users"], - "subdirectory": "security/inventory-unshadowed-users", + "windows-capability": { + "description": "Manage and inventory Windows Capabilities.", + "subdirectory": "management/windows-capability", "steps": [ - "copy policy/main.cf services/cfbs/inventory-unshadowed-users/", - "policy_files services/cfbs/inventory-unshadowed-users/", - "bundles inventory_unshadowed_users:main" + "directory ./ services/cfbs/windows-capability/", + "policy_files services/cfbs/windows-capability/", + "bundles windows_capability" ] }, - "delete-home-dotrhosts": { - "description": "Ensure that ~/.rhosts files are not present, as they present a security risk.", - "subdirectory": "security/delete-home-dotrhosts", + "install-ansible": { + "description": "Optionally install ansible.", + "subdirectory": "software/install-ansible", "steps": [ - "copy policy/main.cf services/cfbs/delete-home-dotrhosts/", - "policy_files services/cfbs/delete-home-dotrhosts/", - "bundles delete_home_dotrhosts:main" + "copy install-ansible.cf services/cfbs/software/install-ansible/install-ansible.cf", + "policy_files services/cfbs/software/install-ansible/install-ansible.cf", + "bundles install_ansible" ] }, - "delete-home-dotshosts": { - "description": "Ensure that ~/.shosts files are not present, as they present a security risk.", - "subdirectory": "security/delete-home-dotshots", + "windows-openssh-server": { + "description": "Optionally install Windows OpenSSH Server.", + "subdirectory": "software/windows", "steps": [ - "copy policy/main.cf services/cfbs/delete-home-dotshots/", - "policy_files services/cfbs/delete-home-dotshots/", - "bundles delete_home_dotshosts:main" + "copy windows-openssh-server.cf services/cfbs/software/windows/windows-openssh-server.cf", + "policy_files services/cfbs/software/windows/windows-openssh-server.cf", + "bundles windows_openssh_server" + ] + }, + "windows-optional-feature": { + "description": "Manage and inventory Windows Optional Features.", + "subdirectory": "management/windows-optional-feature", + "steps": [ + "directory ./ services/cfbs/windows-optional-feature/", + "policy_files services/cfbs/windows-optional-feature/", + "bundles windows_optional_feature" ] } } diff --git a/ci/linting.sh b/ci/linting.sh new file mode 100755 index 00000000..50845f85 --- /dev/null +++ b/ci/linting.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -e + +echo "Running flake8" +flake8 . --ignore=E203,W503,E722,E731 --max-complexity=100 --max-line-length=160 + +echo "Running pyright" +pyright . + +shopt -s globstar +echo "Running black" +black --check --diff . + +echo "Running pyflakes" +pyflakes . diff --git a/examples/git-using-lib/git_using_lib.py b/examples/git-using-lib/git_using_lib.py index b6e203f1..96c742be 100644 --- a/examples/git-using-lib/git_using_lib.py +++ b/examples/git-using-lib/git_using_lib.py @@ -1,40 +1,35 @@ import os -from cfengine import PromiseModule, ValidationError, Result +from cfengine_module_library import PromiseModule, ValidationError, Result class GitPromiseTypeModule(PromiseModule): - def __init__(self, **kwargs): - super().__init__("git_promise_module", "0.0.3", **kwargs) + def __init__(self): + super().__init__("git_promise_module", "0.0.1") - def validate_promise(self, promiser, attributes, meta): + def validate_promise(self, promiser, attributes, metadata): if not promiser.startswith("/"): raise ValidationError(f"File path '{promiser}' must be absolute") - for name, value in attributes.items(): - if name != "repo": - raise ValidationError(f"Unknown attribute '{name}' for git promises") - if name == "repo" and type(value) is not str: - raise ValidationError(f"'repo' must be string for git promise types") + if "repository" not in attributes: + raise ValidationError("Attribute 'repository' is required") - def evaluate_promise(self, promiser, attributes, meta): + def evaluate_promise(self, promiser, attributes, metadata): + url = attributes["repository"] folder = promiser - url = attributes["repo"] - - safe_promiser = promiser.replace(",", "_") if os.path.exists(folder): - return (Result.KEPT, [f"{safe_promiser}_cloned_already"]) + self.log_verbose(f"'{folder}' already exists, nothing to do") + return Result.KEPT self.log_info(f"Cloning '{url}' -> '{folder}'...") os.system(f"git clone {url} {folder} 2>/dev/null") if os.path.exists(folder): self.log_info(f"Successfully cloned '{url}' -> '{folder}'") - return (Result.REPAIRED, [f"{safe_promiser}_cloned"]) + return Result.REPAIRED else: self.log_error(f"Failed to clone '{url}' -> '{folder}'") - return (Result.NOT_KEPT, [f"{safe_promiser}_clone_failed"]) + return Result.NOT_KEPT if __name__ == "__main__": - # GitPromiseTypeModule(record_file_path="/tmp/git_using_lib.log").start() GitPromiseTypeModule().start() diff --git a/examples/gpg/gpg.py b/examples/gpg/gpg.py index 0b002c91..8930c315 100644 --- a/examples/gpg/gpg.py +++ b/examples/gpg/gpg.py @@ -1,4 +1,4 @@ -"""Custom Promise Type module for adding missing gpg keys +"""Custom promise type module for adding missing gpg keys Given some GPG user id values (see man page for all possiblities, there are many) and the ascii value of a key, ensure that the key is present in the given GPG homedir. @@ -42,9 +42,9 @@ """ import json -from subprocess import Popen, PIPE -import sys -from cfengine import PromiseModule, ValidationError, Result +from subprocess import Popen, PIPE, TimeoutExpired +from cfengine_module_library import PromiseModule, ValidationError, Result + class GpgKeysPromiseTypeModule(PromiseModule): def __init__(self): @@ -103,17 +103,17 @@ def gpg_key_present(self, homedir, user_id): proc.communicate() self.log_error(f"Timed out querying for gpg key '{user_id}'") - def validate_promise(self, promiser, attributes, meta): + def validate_promise(self, promiser, attributes, metadata): if not promiser.startswith("/"): raise ValidationError( f"Promiser '{promiser}' for 'gpg_keys' promise must be an absolute path" ) - if not "keylist" in attributes: + if "keylist" not in attributes: raise ValidationError( - f"Required attribute 'keylist' missing for 'gpg_keys' promise" + "Required attribute 'keylist' missing for 'gpg_keys' promise" ) - def evaluate_promise(self, promiser, attributes, meta): + def evaluate_promise(self, promiser, attributes, metadata): keylist_json = self.clean_storejson_output(attributes["keylist"]) self.log_verbose(f"keylist_json is '{keylist_json}'") @@ -141,7 +141,7 @@ def evaluate_promise(self, promiser, attributes, meta): f"Importing ascii key for user id '{user_id}' into gpg homedir '{promiser}'" ) if self.gpg_import_ascii(promiser, key["ascii"]): - if result != Result.NOTKEPT: + if result != Result.NOT_KEPT: result = Result.REPAIRED else: self.log_error(f"Unable to import key for user id '{user_id}'") diff --git a/examples/gpg/password-store-sync.cf b/examples/gpg/password-store-sync.cf index 583264a7..c862c1d8 100644 --- a/examples/gpg/password-store-sync.cf +++ b/examples/gpg/password-store-sync.cf @@ -15,19 +15,25 @@ bundle agent main vars: # for gpg_keys promise, pass a keylist data similar to this proposed standard with the addition of the `ascii` property # https://datatracker.ietf.org/doc/draft-mccain-keylist/ (from https://github.com/firstlookmedia/gpgsync project) - # "keylist" data => readjson("/home/user/keylist.json"); - + # "keylist" data => readjson("/home/user/keylist.json"); # Or assembly the information using some policy data manipulation and jq (https://stedolan.github.io/jq/) magic - "fingerprint_files" slist => findfiles("/home/user/.password-store/.pub-keys/*.fp"); - "keys[$(fingerprint_files)][fingerprint]" string => readfile( $(fingerprint_files) ); - "keys[$(fingerprint_files)][ascii]" string => readfile( regex_replace( $(fingerprint_files), ".fp$", ".asc", "") ); + "fingerprint_files" + slist => findfiles("/home/user/.password-store/.pub-keys/*.fp"); + + "keys[$(fingerprint_files)][fingerprint]" + string => readfile($(fingerprint_files)); + + "keys[$(fingerprint_files)][ascii]" + string => readfile( + regex_replace($(fingerprint_files), ".fp$", ".asc", "") + ); + "keylist" data => mapdata("json_pipe", '$(def.jq) {"keys":[.[]]}', keys); git: "/home/user/.password-store" - repo => "https://github.com/user/my-passwords"; + repository => "https://github.com/user/my-passwords"; gpg_keys: - "/home/user/.gnupg" - keylist => storejson( @(keylist[0]) ); + "/home/user/.gnupg" keylist => storejson(@(keylist[0])); } diff --git a/examples/rss/rss.cf b/examples/rss/rss.cf index 6b70de55..2cc66f2d 100644 --- a/examples/rss/rss.cf +++ b/examples/rss/rss.cf @@ -1,4 +1,3 @@ - promise agent rss { path => "$(example_rss_promise_common.path)"; @@ -15,29 +14,36 @@ bundle agent example_rss_promise # @brief Promise a files content to be the =description= of an item selected from a channel in an RSS feed { rss: - "/tmp/ISS-oldest.txt" # Contains Item description - # REPAIRED if content on disk changes - # KEPT if content on disk does not need changed - # NOTKEPT if unable to fetch rss, unable to re-write content + "/tmp/ISS-oldest.txt" + # Contains Item description + # REPAIRED if content on disk changes + # KEPT if content on disk does not need changed + # NOTKEPT if unable to fetch rss, unable to re-write content + # Default to newest feed => "https://blogs.nasa.gov/stationreport/feed/", - select => "oldest"; # Default to newest + select => "oldest"; - "/tmp/ISS-random.txt" # Contains Item description - # REPAIRED if content on disk changes - # KEPT if content on disk does not need changed - # NOTKEPT if unable to fetch rss, unable to re-write content + "/tmp/ISS-random.txt" + # Contains Item description + # REPAIRED if content on disk changes + # KEPT if content on disk does not need changed + # NOTKEPT if unable to fetch rss, unable to re-write content + # Default to newest feed => "https://blogs.nasa.gov/stationreport/feed/", - select => "random"; # Default to newest + select => "random"; - "/tmp/ISS-newest-from-local-file-source.txt" # Contains Item description - # REPAIRED if content on disk changes - # KEPT if content on disk does not need changed - # NOTKEPT if unable to fetch rss, unable to re-write content + "/tmp/ISS-newest-from-local-file-source.txt" + # Contains Item description + # REPAIRED if content on disk changes + # KEPT if content on disk does not need changed + # NOTKEPT if unable to fetch rss, unable to re-write content + # Default to newest feed => "/tmp/iss.xml", - select => "newest"; # Default to newest + select => "newest"; } bundle agent __main__ { - methods: "example_rss_promise"; + methods: + "example_rss_promise"; } diff --git a/examples/rss/rss.py b/examples/rss/rss.py index c95b007f..7364da52 100755 --- a/examples/rss/rss.py +++ b/examples/rss/rss.py @@ -1,51 +1,62 @@ -import requests, html, re, os, random +import requests +import re +import os +import random import xml.etree.ElementTree as ET -from cfengine import PromiseModule, ValidationError, Result +from cfengine_module_library import PromiseModule, ValidationError, Result class RssPromiseTypeModule(PromiseModule): def __init__(self): super().__init__("rss_promise_module", "0.0.3") - - def validate_promise(self, promiser, attributes, meta): + def validate_promise(self, promiser, attributes, metadata): # check promiser type if type(promiser) is not str: raise ValidationError("invalid type for promiser: expected string") # check that promiser is a valid file path if not self._is_unix_file(promiser) and not self._is_win_file(promiser): - raise ValidationError(f"invalid value '{promiser}' for promiser: must be a filepath") + raise ValidationError( + f"invalid value '{promiser}' for promiser: must be a filepath" + ) # check that required attribute feed is present if "feed" not in attributes: raise ValidationError("Missing required attribute feed") # check that attribute feed has a valid type - feed = attributes['feed'] + feed = attributes["feed"] if type(feed) is not str: raise ValidationError("Invalid type for attribute feed: expected string") # check that attribute feed is a valid file path or url - if not (self._is_unix_file(feed) or self._is_win_file(feed) or self._is_url(feed)): - raise ValidationError(f"Invalid value '{feed}' for attribute feed: must be a file path or url") + if not ( + self._is_unix_file(feed) or self._is_win_file(feed) or self._is_url(feed) + ): + raise ValidationError( + f"Invalid value '{feed}' for attribute feed: must be a file path or url" + ) # additional checks if optional attribute select is present if "select" in attributes: - select = attributes['select'] + select = attributes["select"] # check that attribute select has a valid type if type(select) is not str: - raise ValidationError(f"Invalid type for attribute select: expected string") + raise ValidationError( + "Invalid type for attribute select: expected string" + ) # check that attribute select has a valid value - if select != 'newest' and select != 'oldest' and select != 'random': - raise ValidationError(f"Invalid value '{select}' for attribute select: must be newest, oldest or random") - + if select != "newest" and select != "oldest" and select != "random": + raise ValidationError( + f"Invalid value '{select}' for attribute select: must be newest, oldest or random" + ) - def evaluate_promise(self, promiser, attributes, meta): + def evaluate_promise(self, promiser, attributes, metadata): # get attriute feed - feed = attributes['feed'] + feed = attributes["feed"] # fetch resource resource = self._get_resource(feed) @@ -65,7 +76,6 @@ def evaluate_promise(self, promiser, attributes, meta): return result - def _get_resource(self, path): if self._is_url(path): # fetch from url @@ -73,73 +83,76 @@ def _get_resource(self, path): response = requests.get(path) if response.ok: return response.content - self.log_error(f"Failed to fetch feed from url '{path}'': status code '{response.status_code}'") + self.log_error( + f"Failed to fetch feed from url '{path}'': status code '{response.status_code}'" + ) return None # fetch from file try: self.log_verbose(f"Reading feed from file '{path}'") - with open(path, 'r', encoding='utf-8') as f: + with open(path, "r", encoding="utf-8") as f: resource = f.read() return resource except Exception as e: self.log_error(f"Failed to open file '{path}' for reading: {e}") return None - def _get_items(self, res, path): # extract descriptions in /channel/item try: self.log_verbose(f"Parsing feed '{path}'") items = [] root = ET.fromstring(res) - for item in root.findall('./channel/item'): + for item in root.findall("./channel/item"): for child in item: - if child.tag == 'description': + if child.tag == "description": items.append(child.text) return items except Exception as e: self.log_error(f"Failed to parse feed '{path}': {e}") return None - def _pick_item(self, items, attributes): # Pick newest item as default item = items[0] # Select item from feed if "select" in attributes: - select = attributes['select'] - if select == 'random': + select = attributes["select"] + if select == "random": self.log_verbose("Selecting random item from feed") item = random.choice(items) - elif select == 'oldest': + elif select == "oldest": self.log_verbose("Selecting oldest item from feed") - item = items[- 1] + item = items[-1] else: self.log_verbose("Selecting newest item from feed") else: self.log_verbose("Selecting newest item as default") return item - def _write_promiser(self, item, promiser): file_exist = os.path.isfile(promiser) if file_exist: try: - with open(promiser, 'r', encoding='utf-8') as f: + with open(promiser, "r", encoding="utf-8") as f: if f.read() == item: - self.log_verbose(f"File '{promiser}' exists and is up to date, no changes needed") + self.log_verbose( + f"File '{promiser}' exists and is up to date, no changes needed" + ) return Result.KEPT except Exception as e: self.log_error(f"Failed to open file '{promiser}' for reading: {e}") return Result.NOT_KEPT try: - with open(promiser, 'w', encoding='utf-8') as f: + with open(promiser, "w", encoding="utf-8") as f: if file_exist: - self.log_info(f"File '{promiser}' exists but contents differ, updating content") + self.log_info( + f"File '{promiser}' exists but contents differ, updating content" + ) else: self.log_info(f"File '{promiser}' does not exist, creating file") f.write(item) @@ -148,17 +161,20 @@ def _write_promiser(self, item, promiser): self.log_error(f"Failed to open file '{promiser}' for writing: {e}") return Result.NOT_KEPT - def _is_win_file(self, path): - return re.search(r"^[a-zA-Z]:\\[\\\S|*\S]?.*$", path) != None - + return re.search(r"^[a-zA-Z]:\\[\\\S|*\S]?.*$", path) is not None def _is_unix_file(self, path): - return re.search(r"^(/[^/ ]*)+/?$", path) != None - + return re.search(r"^(/[^/ ]*)+/?$", path) is not None def _is_url(self, path): - return re.search(r"^http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", path) != None + return ( + re.search( + r"^http[s]?://(?:[a-zA-Z]|[0-9]|[-\$_\@\.\&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", + path, + ) + is not None + ) if __name__ == "__main__": diff --git a/examples/site-up/site_up.cf b/examples/site-up/site_up.cf index c14777ce..8f50db3b 100644 --- a/examples/site-up/site_up.cf +++ b/examples/site-up/site_up.cf @@ -1,21 +1,18 @@ promise agent site_up # @brief Define site_up promise type { - path => "/var/cfengine/modules/promises/site_up.py"; - interpreter => "/usr/bin/python3.8"; + path => "/var/cfengine/modules/promises/site_up.py"; + interpreter => "/usr/bin/python3.8"; } bundle agent site_up_autorun -# @brief Example illustrating use of site_up promise type { meta: - - "tags" slist => { "autorun" }; + "tags" slist => { "autorun" }; site_up: - "http://172.28.128.10"; - "https://cfengine.com/"; - "https://cfengine2.com/"; - "https://unavailable.com" - skip_ssl_verification => "true"; + "http://172.28.128.10"; + "https://cfengine.com/"; + "https://cfengine2.com/"; + "https://unavailable.com" skip_ssl_verification => "true"; } diff --git a/examples/site-up/site_up.py b/examples/site-up/site_up.py index 5341aa0d..dc05e810 100644 --- a/examples/site-up/site_up.py +++ b/examples/site-up/site_up.py @@ -2,18 +2,26 @@ import ssl import urllib.request import urllib.error -from cfengine import PromiseModule, ValidationError, Result +from cfengine_module_library import PromiseModule, ValidationError, Result class SiteUpPromiseTypeModule(PromiseModule): def __init__(self): - super().__init__("site_up_promise_module", "0.0.2") + super().__init__("site_up_promise_module", "0.0.3") - def validate_promise(self, promiser, attributes, meta): + def is_url_valid(self, url): + regex = re.compile( + r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)", + re.IGNORECASE, + ) + return re.match(regex, url) is not None + + def validate_promise(self, promiser, attributes, metadata): if not self.is_url_valid(promiser): raise ValidationError(f"URL '{promiser}' is invalid") - def evaluate_promise(self, url, attributes, meta): + def evaluate_promise(self, promiser, attributes, metadata): + url = promiser ssl_ctx = ssl.create_default_context() if ( "skip_ssl_verification" in attributes @@ -23,8 +31,8 @@ def evaluate_promise(self, url, attributes, meta): error = None try: - code = urllib.request.urlopen(url, context=ssl_ctx).getcode() - self.log_info(f"Site '{url}' is UP!") + urllib.request.urlopen(url, context=ssl_ctx).getcode() + self.log_verbose(f"Site '{url}' is UP!") return Result.KEPT except urllib.error.HTTPError as e: # HTTPError exception returns response code and useful when handling exotic HTTP errors @@ -39,13 +47,6 @@ def evaluate_promise(self, url, attributes, meta): self.log_error(error) return Result.NOT_KEPT - def is_url_valid(self, url): - regex = re.compile( - r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)", - re.IGNORECASE, - ) - return re.match(regex, url) is not None - if __name__ == "__main__": SiteUpPromiseTypeModule().start() diff --git a/inventory/inventory-fde/README.md b/inventory/inventory-fde/README.md new file mode 100644 index 00000000..581365b8 --- /dev/null +++ b/inventory/inventory-fde/README.md @@ -0,0 +1,56 @@ +Full disk encryption (FDE) protects data at rest by encrypting entire block devices. +This module detects mounted volumes backed by dm-crypt (LUKS1, LUKS2, or plain dm-crypt) on Linux systems and reports whether all, some, or none of the non-virtual block device filesystems are encrypted. + +Basic detection (encryption status, method, volume lists) is performed entirely through virtual filesystem reads (`/sys/block/` and `/proc/mounts`). +When `dmsetup` and `cryptsetup` are available, the module additionally reports the active cipher and LUKS keyslot details (per-keyslot cipher and PBKDF algorithm). + +## How it works + +1. Enumerates device-mapper block devices from `/sys/block/dm-*` +2. Reads each device's DM subsystem UUID from `/sys/block/dm-N/dm/uuid` +3. Identifies crypt devices by the `CRYPT-` prefix in the UUID +4. Parses `/proc/mounts` to find all non-virtual block device mounts (excluding loop devices) +5. Classifies each mount as encrypted or unencrypted by checking if its device matches a crypt device path +6. If `dmsetup` is available, reads the active cipher from `dmsetup table` for each crypt device +7. If `cryptsetup` is available, reads LUKS keyslot metadata (cipher and PBKDF per slot) via `cryptsetup luksDump` + +## Inventory + +- **Full disk encryption enabled** - `yes` if all non-virtual block device filesystems are encrypted, `partial` if some are encrypted and some are not, `no` if none are encrypted. +- **Full disk encryption methods** - The encryption type(s) detected, e.g. `LUKS2`, `LUKS1`, `PLAIN`. Empty list when no encryption is found. +- **Full disk encryption volumes** - List of mountpoints backed by encrypted devices. +- **Unencrypted volumes** - List of mountpoints on non-virtual block devices that are not encrypted. +- **Full disk encryption volume ciphers** - The active dm-crypt cipher per volume, e.g. `/ : aes-xts-plain64`. Requires `dmsetup`. +- **Full disk encryption keyslot info** - LUKS keyslot cipher and PBKDF per volume, e.g. `/ : 0:aes-xts-plain64/argon2id`. Requires `cryptsetup`. Not available for plain dm-crypt (no keyslots). + +[![Inventory in Mission Portal](https://raw.githubusercontent.com/cfengine/modules/master/inventory/inventory-fde/inventory-fde-mission-portal.png)](https://raw.githubusercontent.com/cfengine/modules/master/inventory/inventory-fde/inventory-fde-mission-portal.png) + +## Example + +A system with LUKS2-encrypted root but unencrypted `/boot` and `/boot/efi`: + +``` +$ sudo cf-agent -Kf ./inventory-fde.cf --show-evaluated-vars=inventory_fde +Variable name Variable value Meta tags Comment +inventory_fde:main.fde_enabled partial source=promise,inventory,attribute_name=Full disk encryption enabled +inventory_fde:main.fde_method {"LUKS2"} source=promise,inventory,attribute_name=Full disk encryption methods +inventory_fde:main.fde_volumes {"/"} source=promise,inventory,attribute_name=Full disk encryption volumes +inventory_fde:main.unencrypted_volumes {"/boot","/boot/efi"} source=promise,inventory,attribute_name=Unencrypted volumes +inventory_fde:main.fde_volume_cipher {"/ : aes-xts-plain64"} source=promise,inventory,attribute_name=Full disk encryption volume ciphers +inventory_fde:main.fde_keyslot_info {"/ : 0:aes-xts-plain64/argon2id"} source=promise,inventory,attribute_name=Full disk encryption keyslot info +``` + +## Testing + +A helper script is included to create and tear down a LUKS2 test volume on a loopback device: + +``` +sudo ./test-encrypted-volume.sh setup # Create and mount test volume +sudo cf-agent -KIf ./inventory-fde.cf --show-evaluated-vars=inventory_fde +sudo ./test-encrypted-volume.sh teardown # Clean up +``` + +## Platform + +- Linux only (requires `/sys/block/` and `/proc/mounts`) +- Cipher and keyslot inventory requires `dmsetup` and/or `cryptsetup` (typically available on systems with dm-crypt) diff --git a/inventory/inventory-fde/inventory-fde-mission-portal.png b/inventory/inventory-fde/inventory-fde-mission-portal.png new file mode 100644 index 00000000..89f8ebc7 Binary files /dev/null and b/inventory/inventory-fde/inventory-fde-mission-portal.png differ diff --git a/inventory/inventory-fde/inventory-fde.cf b/inventory/inventory-fde/inventory-fde.cf new file mode 100644 index 00000000..9231f26f --- /dev/null +++ b/inventory/inventory-fde/inventory-fde.cf @@ -0,0 +1,384 @@ +body file control +{ + namespace => "inventory_fde"; +} + +# Duplicated from the CFEngine standard library so this module can be parsed +# and tested standalone without loading the full masterfiles. +# _tidy: lib/files.cf body delete tidy +# _in_shell: lib/commands.cf body contain in_shell +body delete _tidy +{ + dirlinks => "delete"; + rmdirs => "true"; +} + +body contain _in_shell +{ + useshell => "useshell"; +} + +bundle agent main +# @brief Inventory full disk encryption status +# @inventory Full disk encryption enabled - Whether all non-virtual mounted filesystems use dm-crypt encryption (yes, partial, or no). +# @inventory Full disk encryption methods - The encryption type(s) in use, e.g. LUKS2, LUKS1, PLAIN. +# @inventory Full disk encryption volumes - List of mountpoints backed by encrypted devices, e.g. /. +# @inventory Unencrypted volumes - List of mountpoints on non-virtual block devices that are not encrypted, e.g. /boot, /boot/efi. +# @inventory Full disk encryption volume ciphers - The active dm-crypt cipher per volume, e.g. / : aes-xts-plain64. +# @inventory Full disk encryption keyslot info - LUKS keyslot cipher and PBKDF per volume, e.g. / : 0:aes-xts-plain64/argon2id. +{ + vars: + linux:: + "_dmsetup" string => "/sbin/dmsetup"; + "_cryptsetup" string => "/sbin/cryptsetup"; + + classes: + linux:: + "_have_dmsetup" expression => isexecutable("${_dmsetup}"); + "_have_cryptsetup" expression => isexecutable("${_cryptsetup}"); + + # Flag each dm device that has a CRYPT uuid + "_dm_is_crypt_${_dm_devices}" + expression => regcmp("CRYPT-.*", "${_dm_uuid[${_dm_devices}]}"); + + # Classify crypt type per device + "_dm_is_luks2_${_dm_devices}" + expression => strcmp("LUKS2", "${_dm_crypt_type[${_dm_devices}]}"); + + "_dm_is_luks1_${_dm_devices}" + expression => strcmp("LUKS1", "${_dm_crypt_type[${_dm_devices}]}"); + + # Classify each mount: real block device? (starts with /dev/, not a loop device) + "_is_real_block_${_mnt_idx}" + expression => regcmp( + "/dev/(?!loop)\S+", "${_mnt_data[${_mnt_idx}][0]}" + ); + + # Classify each real block mount: is device in the crypt paths list? + "_is_encrypted_${_mnt_idx}" + expression => regcmp( + "(${_crypt_paths_regex})", "${_mnt_data[${_mnt_idx}][0]}" + ), + if => canonify("_is_real_block_${_mnt_idx}"); + + # LUKS1: flag enabled keyslots (slots 0-7, all share global cipher, all use PBKDF2) + "_luks1_slot_enabled_${_dm_devices}_${_luks1_slots}" + expression => regcmp( + "(?s).*Key Slot ${_luks1_slots}: ENABLED.*", + "${_luks1_dump[${_dm_devices}]}" + ), + if => canonify("_dm_is_luks1_${_dm_devices}"); + + # Summary classes + "_has_encrypted" + expression => isgreaterthan(length(_encrypted_mountpoints), 0); + + "_has_unencrypted" + expression => isgreaterthan(length(_unencrypted_mountpoints), 0); + + vars: + linux:: + # Enumerate all device-mapper block devices + "_dm_devices" slist => lsdir("/sys/block", "dm-\d+", false); + + # Read the DM subsystem uuid and name for each dm device + "_dm_uuid[${_dm_devices}]" + string => readfile("/sys/block/${_dm_devices}/dm/uuid"), + if => fileexists("/sys/block/${_dm_devices}/dm/uuid"); + + "_dm_name[${_dm_devices}]" + string => readfile("/sys/block/${_dm_devices}/dm/name"), + if => fileexists("/sys/block/${_dm_devices}/dm/name"); + + # Build list of crypt device paths (both /dev/mapper/ and /dev/) + "_crypt_mapper_path[${_dm_devices}]" + string => "/dev/mapper/${_dm_name[${_dm_devices}]}", + if => canonify("_dm_is_crypt_${_dm_devices}"); + + "_crypt_dm_path[${_dm_devices}]" + string => "/dev/${_dm_devices}", + if => canonify("_dm_is_crypt_${_dm_devices}"); + + "_all_crypt_paths" + slist => { getvalues(_crypt_mapper_path), getvalues(_crypt_dm_path) }; + + # Build a regex alternation of all crypt device paths for matching + "_crypt_paths_regex" + string => join( + "|", + maplist( + regex_replace("$(this)", "([./])", "\\\1", "g"), _all_crypt_paths + ) + ); + + # Extract the encryption type for crypt devices + # UUID format: CRYPT--- + "_dm_crypt_type[${_dm_devices}]" + string => regex_replace( + "${_dm_uuid[${_dm_devices}]}", "^CRYPT-([^-]+)-.*", "\1", "" + ), + if => canonify("_dm_is_crypt_${_dm_devices}"); + + # Underlying block device for each crypt device (for cryptsetup luksDump) + "_dm_slaves[${_dm_devices}]" + slist => lsdir("/sys/block/${_dm_devices}/slaves", "[a-z].*", false), + if => canonify("_dm_is_crypt_${_dm_devices}"); + + "_dm_slave_dev[${_dm_devices}]" + string => "/dev/${_dm_slaves[${_dm_devices}]}", + if => canonify("_dm_is_crypt_${_dm_devices}"); + + # Parse /proc/mounts into indexed array + # Columns: 0=device, 1=mountpoint, 2=fstype, 3=options, 4=dump, 5=pass + "_n_mnt_lines" + int => readstringarrayidx( + "_mnt_data", "/proc/mounts", "\s*#[^\n]*", "\s+", inf, inf + ); + + "_mnt_idx" slist => getindices(_mnt_data); + + # Collect all real block device mountpoints + "_all_real_mountpoint[${_mnt_idx}]" + string => "${_mnt_data[${_mnt_idx}][1]}", + if => canonify("_is_real_block_${_mnt_idx}"); + + # Collect encrypted mountpoints (subset of real block mounts) + "_encrypted_mountpoint[${_mnt_idx}]" + string => "${_mnt_data[${_mnt_idx}][1]}", + if => and( + canonify("_is_real_block_${_mnt_idx}"), + canonify("_is_encrypted_${_mnt_idx}") + ); + + # Map dm device to its mountpoint via cross-iteration + "_dm_mountpoint[${_dm_devices}]" + string => "${_mnt_data[${_mnt_idx}][1]}", + if => and( + canonify("_dm_is_crypt_${_dm_devices}"), + regcmp( + "(/dev/mapper/${_dm_name[${_dm_devices}]}|/dev/${_dm_devices})", + "${_mnt_data[${_mnt_idx}][0]}" + ) + ); + + # Derive unencrypted mountpoints as the difference + "_all_real_mountpoints" slist => getvalues(_all_real_mountpoint); + "_encrypted_mountpoints" slist => getvalues(_encrypted_mountpoint); + + "_unencrypted_mountpoints" + slist => difference(_all_real_mountpoints, _encrypted_mountpoints); + + # --- Active cipher via dmsetup table --- + _have_dmsetup:: + # dmsetup table format: "0 crypt " + "_dm_active_cipher[${_dm_devices}]" + string => regex_replace( + execresult( + "${_dmsetup} table ${_dm_name[${_dm_devices}]}", "noshell" + ), + "^\d+\s+\d+\s+crypt\s+(\S+)\s+.*$", + "\1", + "" + ), + if => canonify("_dm_is_crypt_${_dm_devices}"); + + # --- LUKS2 keyslot info via cached JSON metadata --- + _have_cryptsetup:: + "_luks2_cache[${_dm_devices}]" + string => "$(sys.statedir)/inventory_fde_luks2_${_dm_devices}.json", + if => canonify("_dm_is_luks2_${_dm_devices}"); + + "_luks2_cache_mtime[${_dm_devices}]" + string => filestat("${_luks2_cache[${_dm_devices}]}", "mtime"), + if => and( + canonify("_dm_is_luks2_${_dm_devices}"), + fileexists("${_luks2_cache[${_dm_devices}]}") + ); + + # --- LUKS1 keyslot info via text parsing --- + _have_cryptsetup:: + "_luks1_slots" slist => { "0", "1", "2", "3", "4", "5", "6", "7" }; + + "_luks1_dump[${_dm_devices}]" + string => execresult( + "${_cryptsetup} luksDump ${_dm_slave_dev[${_dm_devices}]}", "noshell" + ), + if => canonify("_dm_is_luks1_${_dm_devices}"); + + # LUKS1 global cipher: "Cipher name" + "Cipher mode" + "_luks1_cipher_name[${_dm_devices}]" + string => regex_replace( + "${_luks1_dump[${_dm_devices}]}", + "(?s).*Cipher name:\s+(\S+).*", + "\1", + "" + ), + if => canonify("_dm_is_luks1_${_dm_devices}"); + + "_luks1_cipher_mode[${_dm_devices}]" + string => regex_replace( + "${_luks1_dump[${_dm_devices}]}", + "(?s).*Cipher mode:\s+(\S+).*", + "\1", + "" + ), + if => canonify("_dm_is_luks1_${_dm_devices}"); + + # Build per-keyslot summary for each ENABLED slot + "_luks1_ks_entry[${_dm_devices}][${_luks1_slots}]" + string => "${_luks1_slots}:${_luks1_cipher_name[${_dm_devices}]}-${_luks1_cipher_mode[${_dm_devices}]}/pbkdf2", + if => and( + canonify("_dm_is_luks1_${_dm_devices}"), + canonify("_luks1_slot_enabled_${_dm_devices}_${_luks1_slots}") + ); + + "_luks1_ks_entries[${_dm_devices}]" + slist => getvalues("_luks1_ks_entry[${_dm_devices}]"), + if => canonify("_dm_is_luks1_${_dm_devices}"); + + "_dm_keyslot_info[${_dm_devices}]" + string => join(", ", sort("_luks1_ks_entries[${_dm_devices}]", "lex")), + if => canonify("_dm_is_luks1_${_dm_devices}"); + + # --- Inventory attributes --- + linux:: + "fde_enabled" + string => ifelse( + "_has_encrypted.!_has_unencrypted", + "yes", + "_has_encrypted._has_unencrypted", + "partial", + "no" + ), + meta => { "inventory", "attribute_name=Full disk encryption enabled" }; + + "fde_method" + slist => unique(getvalues(_dm_crypt_type)), + meta => { "inventory", "attribute_name=Full disk encryption methods" }; + + _has_encrypted:: + "fde_volumes" + slist => unique(_encrypted_mountpoints), + meta => { "inventory", "attribute_name=Full disk encryption volumes" }; + + _has_unencrypted:: + "unencrypted_volumes" + slist => unique(_unencrypted_mountpoints), + meta => { "inventory", "attribute_name=Unencrypted volumes" }; + + # Build per-volume cipher and keyslot strings with mountpoint prefix + _have_dmsetup:: + "_volume_cipher_entry[${_dm_devices}]" + string => "${_dm_mountpoint[${_dm_devices}]} : ${_dm_active_cipher[${_dm_devices}]}", + if => and( + canonify("_dm_is_crypt_${_dm_devices}"), + isvariable("_dm_mountpoint[${_dm_devices}]") + ); + + _have_cryptsetup:: + "_keyslot_info_entry[${_dm_devices}]" + string => "${_dm_mountpoint[${_dm_devices}]} : ${_luks2_ks_${_dm_devices}[keyslots]}", + if => and( + canonify("_dm_is_luks2_${_dm_devices}"), + isvariable("_dm_mountpoint[${_dm_devices}]"), + isvariable("_luks2_ks_${_dm_devices}[keyslots]") + ); + + "_keyslot_info_entry[${_dm_devices}]" + string => "${_dm_mountpoint[${_dm_devices}]} : ${_dm_keyslot_info[${_dm_devices}]}", + if => and( + canonify("_dm_is_luks1_${_dm_devices}"), + isvariable("_dm_mountpoint[${_dm_devices}]") + ); + + _has_encrypted._have_dmsetup:: + "fde_volume_cipher" + slist => getvalues(_volume_cipher_entry), + meta => { + "inventory", "attribute_name=Full disk encryption volume ciphers" + }; + + _has_encrypted._have_cryptsetup:: + "fde_keyslot_info" + slist => getvalues(_keyslot_info_entry), + meta => { + "inventory", "attribute_name=Full disk encryption keyslot info" + }; + + files: + _have_cryptsetup:: + # Delete LUKS2 JSON cache if older than 24 hours + "${_luks2_cache[${_dm_devices}]}" + delete => _tidy, + if => and( + canonify("_dm_is_luks2_${_dm_devices}"), + fileexists("${_luks2_cache[${_dm_devices}]}"), + isgreaterthan( + format( + "%d", + eval("$(sys.systime) - ${_luks2_cache_mtime[${_dm_devices}]}") + ), + "86400" + ) + ); + + commands: + _have_cryptsetup:: + "${_cryptsetup}" + arglist => { + "luksDump", + "--dump-json-metadata", + "${_dm_slave_dev[${_dm_devices}]}", + ">", + "${_luks2_cache[${_dm_devices}]}", + }, + contain => _in_shell, + if => and( + canonify("_dm_is_luks2_${_dm_devices}"), + not(fileexists("${_luks2_cache[${_dm_devices}]}")) + ); + + methods: + _have_cryptsetup:: + # Parse LUKS2 JSON and return keyslot summary via bundle_return_value_index + "luks2_${_dm_devices}" + usebundle => luks2_keyslot_info("${_luks2_cache[${_dm_devices}]}"), + useresult => "_luks2_ks_${_dm_devices}", + if => and( + canonify("_dm_is_luks2_${_dm_devices}"), + fileexists("${_luks2_cache[${_dm_devices}]}") + ); + + reports: + !linux.verbose_mode:: + "$(this.promise_filename): $(this.namespace):$(this.bundle) is currently only instrumented for Linux. Please consider making a pull request or filing a ticket to request your specific platform."; +} + +bundle agent luks2_keyslot_info(cache_file) +# @brief Parse LUKS2 JSON metadata and return keyslot summary +{ + vars: + "_json" data => readjson("${cache_file}"); + "_ks_idx" slist => getindices("_json[keyslots]"); + + # Build per-keyslot summary: ":/" + "_ks_entry[${_ks_idx}]" + string => "${_ks_idx}:${_json[keyslots][${_ks_idx}][area][encryption]}/${_json[keyslots][${_ks_idx}][kdf][type]}"; + + "_ks_entries" slist => getvalues(_ks_entry); + "_keyslots" string => join(", ", sort(_ks_entries, "lex")); + + reports: + "${_keyslots}" bundle_return_value_index => "keyslots"; +} + +body file control +{ + namespace => "default"; +} + +bundle agent __main__ +{ + methods: + "inventory_fde:main"; +} diff --git a/inventory/inventory-fde/test-encrypted-volume.sh b/inventory/inventory-fde/test-encrypted-volume.sh new file mode 100755 index 00000000..bf6bb75c --- /dev/null +++ b/inventory/inventory-fde/test-encrypted-volume.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Create or tear down a test LUKS2 encrypted volume for inventory-fde testing. +# +# Usage: +# sudo ./test-encrypted-volume.sh setup # Create and mount test volume +# sudo ./test-encrypted-volume.sh teardown # Unmount and remove test volume +# +# The test volume uses a loopback device with a hardcoded passphrase ("testpass") +# and mounts at /mnt/fde-test. Requires cryptsetup and root privileges. + +set -euo pipefail + +IMG="/tmp/fde-test.img" +LOOP="/dev/loop100" +NAME="fde-test" +MNT="/mnt/${NAME}" +PASS="testpass" + +setup() { + if [ -e "/dev/mapper/${NAME}" ]; then + echo "Test volume already exists at /dev/mapper/${NAME}" + exit 1 + fi + + echo "Creating 100MB disk image..." + dd if=/dev/zero of="${IMG}" bs=1M count=100 status=progress + + echo "Setting up loop device ${LOOP}..." + losetup "${LOOP}" "${IMG}" + + echo "Formatting as LUKS2..." + echo -n "${PASS}" | cryptsetup luksFormat --type luks2 "${LOOP}" --key-file=- + + echo "Opening LUKS volume as ${NAME}..." + echo -n "${PASS}" | cryptsetup open "${LOOP}" "${NAME}" --key-file=- + + echo "Creating ext4 filesystem..." + mkfs.ext4 -q "/dev/mapper/${NAME}" + + echo "Mounting at ${MNT}..." + mkdir -p "${MNT}" + mount "/dev/mapper/${NAME}" "${MNT}" + + echo "" + echo "Test volume ready. Verify with:" + echo " cf-agent -KIf ./inventory-fde.cf --show-evaluated-vars=inventory_fde" + echo "" + echo "Tear down with:" + echo " sudo $0 teardown" +} + +teardown() { + echo "Tearing down test volume..." + + if mountpoint -q "${MNT}" 2>/dev/null; then + echo "Unmounting ${MNT}..." + umount "${MNT}" + fi + + if [ -e "/dev/mapper/${NAME}" ]; then + echo "Closing LUKS volume ${NAME}..." + cryptsetup close "${NAME}" + fi + + if losetup "${LOOP}" &>/dev/null; then + echo "Detaching loop device ${LOOP}..." + losetup -d "${LOOP}" + fi + + if [ -f "${IMG}" ]; then + echo "Removing disk image ${IMG}..." + rm -f "${IMG}" + fi + + if [ -d "${MNT}" ]; then + rmdir "${MNT}" 2>/dev/null || true + fi + + # Clean up cached LUKS2 JSON metadata + rm -f /var/cfengine/state/inventory_fde_luks2_*.json + + echo "Teardown complete." +} + +case "${1:-}" in + setup) + setup + ;; + teardown) + teardown + ;; + *) + echo "Usage: sudo $0 {setup|teardown}" + exit 1 + ;; +esac diff --git a/inventory/inventory-fwupd/README.org b/inventory/inventory-fwupd/README.org new file mode 100644 index 00000000..03199461 --- /dev/null +++ b/inventory/inventory-fwupd/README.org @@ -0,0 +1,111 @@ +Surface fwupd state as inventory attributes: current firmware versions +and vendors, pending updates from LVFS, and Host Security Identifier +(HSI) security posture. + +Pair with *manage-fwupd* to optionally apply firmware updates for +devices matching an allow-list. + +* Requirements + +- Linux (silently no-ops on other platforms) +- =fwupd= package for full functionality (manage-fwupd installs it + automatically). Without fwupd, the module still runs but reports + =Firmware update status= as =FWUPD_MISSING=. +- =fwupd-refresh.timer= enabled so the LVFS firmware catalog stays + current (manage-fwupd handles this automatically) + +* Mission Portal + +The inventory attributes appear in Mission Portal's column selector +under "fwupd": + +[[https://raw.githubusercontent.com/cfengine/modules/master/inventory/inventory-fwupd/mp-inventory-fwupd-columns.png]] + +* Inventory Attributes + +** Rolled-up status + +| Attribute | Values | +|----------------------------+-----------------------------------------------------------------| +| *Firmware update status* | =OK= -- no pending updates | +| | =UPDATES_AVAILABLE= -- one or more devices have pending updates | +| | =NO_DEVICES= -- fwupd present but no updatable devices | +| | =FWUPD_MISSING= -- fwupd is not installed | + +** Per-device attributes + +For every device fwupd reports (keyed by DeviceId): + +| Attribute | Format | +|------------------------------------+---------------------------------------------------------| +| *Firmware devices* | =Name | Vendor | vX.Y.Z | [plugin]= | +| *Firmware device pending update* | =Name: current -> new= (only when an update is pending) | + +** HSI attributes + +| Attribute | Format | +|---------------------------------+------------------------------------------------------------| +| *Firmware HSI level* | =HSI:0= through =HSI:4= | +| *Firmware HSI failing* | =L: = (slist) | +| *Firmware HSI passing* | =L: = (slist) | + +*Firmware HSI level* is the rolled-up Host Security Identifier level. +fwupd walks levels 1--4 sequentially; the result is the highest level +where all attributes pass, stopping at the first level with any failure. + +*Firmware HSI failing* and *Firmware HSI passing* are slists of the +security checks fwupd reported, split by outcome (e.g. =L1: TPM v2.0=). +The check set is hardware dependent, so a check in neither list is not +applicable to this host. *compliance-report-fwupd* matches an exact item +against *Firmware HSI failing* for its per-check conditions. + +Items keep the =L:= prefix because the name alone is not unique: +fwupd reports two distinct checks named =Platform debugging=, at L1 +(Intel DCI) and L2 (debug locked). Two normalizations keep items stable: + +- *Name normalization:* The CSME version attribute is emitted as + =L1: CSME version= regardless of the firmware version string fwupd + reports (which varies per host). +- *Level normalization:* fwupd marks some runtime checks at HsiLevel 0 + even though they contribute to scored HSI levels. The module maps + these to their specification levels: =UEFI secure boot= is emitted + at L1 (not L0) and =CET OS Support= at L3 (not L0). + +* Measurements + +The following values are emitted as =cf-monitord= measurements for +time-series tracking in Mission Portal: + +| Measurement | Units | Description | +|----------------------------------+---------+-----------------------------------------| +| =firmware_devices_total= | devices | Number of devices fwupd is tracking | +| =firmware_updates_available= | devices | Number of devices with a pending update | +| =firmware_hsi_failing= | checks | Number of failing HSI security checks | + +These appear in Mission Portal monitoring graphs as +=firmware_devices_total=, =firmware_updates_available=, and +=firmware_hsi_failing=. + +[[https://raw.githubusercontent.com/cfengine/modules/master/inventory/inventory-fwupd/mp-monitoring-firmware-devices.png]] + +* Classes + +The module defines namespace-scoped classes for platform-specific +compliance report targeting: + +| Class | Source | Matches | +|--------------------------+--------------------------------+--------------------------------| +| =fwupd_cpu_vendor_intel= | =/proc/cpuinfo= vendor_id | =GenuineIntel= | +| =fwupd_cpu_vendor_amd= | =/proc/cpuinfo= vendor_id | =AuthenticAMD= | +| =fwupd_oem_vendor_hp= | =/sys/class/dmi/id/sys_vendor= | =HP Inc.= or =Hewlett-Packard= | + +These classes are used by *compliance-report-fwupd* =host_filter= +fields to restrict Intel-only, AMD-only, and HP-only conditions to +the relevant hardware. + +* Limitations + +The module is /read-only/ -- it never applies firmware updates. +Use *manage-fwupd* for that. + + diff --git a/inventory/inventory-fwupd/fwupd-inventory.mustache b/inventory/inventory-fwupd/fwupd-inventory.mustache new file mode 100644 index 00000000..c5f8e838 --- /dev/null +++ b/inventory/inventory-fwupd/fwupd-inventory.mustache @@ -0,0 +1,3 @@ +^context=inventory_fwupd_cache +^meta=inventory,attribute_name=Firmware update status +=fwupd_status={{{status}}} diff --git a/inventory/inventory-fwupd/mp-inventory-fwupd-columns.png b/inventory/inventory-fwupd/mp-inventory-fwupd-columns.png new file mode 100644 index 00000000..a330441d Binary files /dev/null and b/inventory/inventory-fwupd/mp-inventory-fwupd-columns.png differ diff --git a/inventory/inventory-fwupd/mp-monitoring-firmware-devices.png b/inventory/inventory-fwupd/mp-monitoring-firmware-devices.png new file mode 100644 index 00000000..33115600 Binary files /dev/null and b/inventory/inventory-fwupd/mp-monitoring-firmware-devices.png differ diff --git a/inventory/inventory-fwupd/policy.cf b/inventory/inventory-fwupd/policy.cf new file mode 100644 index 00000000..0bcdb8df --- /dev/null +++ b/inventory/inventory-fwupd/policy.cf @@ -0,0 +1,635 @@ +# inventory-fwupd policy +# +# Inventories hardware firmware versions, pending firmware updates, and the +# Host Security Identifier (HSI) attribute scores reported by fwupd. +# +# Implementation notes: +# - Device list, pending updates and HSI attributes come from fwupdmgr, into +# JSON caches refreshed past their TTL. validjson() checks each payload +# before caching, so readjson() never sees a bad file. +# - fwupd's own /var/cache/fwupd/devices.json is often zero-length, so it is +# not used (ENT-14409). +# - All inventory variables are emitted via a single module-protocol cache +# rendered from JSON with an inline_mustache template. The cache is +# consumed in one call to read_module_protocol(), which loads dozens of +# inventory-tagged variables at far lower runtime cost than iterating the +# raw JSON with classic arrays. +# +# Rolled-up status (Mission Portal attribute "Firmware update status"): +# OK - fwupd present, no pending updates +# UPDATES_AVAILABLE - one or more devices have firmware updates pending +# NO_DEVICES - fwupd present but reports no updatable devices +# FWUPD_MISSING - fwupd not installed on the host +bundle agent inventory_fwupd_main +{ + vars: + linux:: + # Public API: referenced by manage-fwupd and other consumers. + "fwupdmgr" + string => ifelse( + fileexists("/usr/bin/fwupdmgr"), + "/usr/bin/fwupdmgr", + fileexists("/usr/local/bin/fwupdmgr"), + "/usr/local/bin/fwupdmgr", + "/usr/bin/fwupdmgr" + ); + + "_fwupd_dir" string => "$(sys.statedir)/fwupd"; + "_devices_src" string => "$(_fwupd_dir)/inventory_devices.json"; + "_updates_src" string => "$(_fwupd_dir)/inventory_updates.json"; + "_security_src" string => "$(_fwupd_dir)/inventory_security.json"; + "_cache" string => "$(_fwupd_dir)/inventory_cache"; + "_template" string => "$(this.promise_dirname)/fwupd-inventory.mustache"; + + # TTLs in seconds for the fwupdmgr-derived JSON caches. + # 12 h: + "_devices_ttl" string => "43200"; + "_updates_ttl" string => "43200"; + + # 24 h: + "_security_ttl" string => "86400"; + + # Pre-compute file mtimes here rather than calling filestat() inline + # inside class expressions: nested $(filestat($(path), mtime)) does not + # expand reliably inside isgreaterthan(), so the staleness comparisons + # silently never match. Defaulting to "0" when a file is missing lets + # the class expressions below stay simple and total. + "_cache_mtime" + string => ifelse( + fileexists("$(_cache)"), filestat("$(_cache)", "mtime"), "0" + ); + + "_devices_mtime" + string => ifelse( + fileexists("$(_devices_src)"), + filestat("$(_devices_src)", "mtime"), + "0" + ); + + "_updates_mtime" + string => ifelse( + fileexists("$(_updates_src)"), + filestat("$(_updates_src)", "mtime"), + "0" + ); + + "_security_mtime" + string => ifelse( + fileexists("$(_security_src)"), + filestat("$(_security_src)", "mtime"), + "0" + ); + + "_devices_age" + string => eval("$(sys.systime) - $(_devices_mtime)", "math", "infix"); + + "_updates_age" + string => eval("$(sys.systime) - $(_updates_mtime)", "math", "infix"); + + "_security_age" + string => eval("$(sys.systime) - $(_security_mtime)", "math", "infix"); + + # /proc/1 mtime is when PID 1 (init) was exec'd, i.e., boot time on + # Linux. Used below to force a refresh of the updates cache after a + # reboot, so consumers like manage-fwupd don't act on a pre-boot + # snapshot that still lists devices whose updates have already been + # activated. + "_boot_mtime" + string => ifelse( + fileexists("/proc/1"), filestat("/proc/1", "mtime"), "0" + ); + + classes: + linux:: + "have_fwupdmgr" + scope => "namespace", + expression => isexecutable("$(fwupdmgr)"); + + "_have_devices" expression => fileexists("$(_devices_src)"); + + # CPU vendor classes for platform-specific compliance conditions. + # /proc/cpuinfo vendor_id: "GenuineIntel" or "AuthenticAMD". + "fwupd_cpu_vendor_intel" + scope => "namespace", + meta => { "report" }, + expression => regline("vendor_id.*GenuineIntel", "/proc/cpuinfo"); + + "fwupd_cpu_vendor_amd" + scope => "namespace", + meta => { "report" }, + expression => regline("vendor_id.*AuthenticAMD", "/proc/cpuinfo"); + + # OEM vendor class for HP-specific compliance conditions. + # /sys/class/dmi/id/sys_vendor: "HP Inc." (post-2015) or + # "Hewlett-Packard" (pre-2015). + "fwupd_oem_vendor_hp" + scope => "namespace", + meta => { "report" }, + expression => regline( + "(HP Inc\.|Hewlett-Packard)", "/sys/class/dmi/id/sys_vendor" + ); + + linux.have_fwupdmgr:: + "_devices_stale" not => fileexists("$(_devices_src)"); + + "_devices_stale" + expression => isgreaterthan("$(_devices_age)", "$(_devices_ttl)"), + if => fileexists("$(_devices_src)"); + + # Firmware updates activate on boot, so device versions change then. + "_devices_stale" + expression => islessthan("$(_devices_mtime)", "$(_boot_mtime)"), + if => fileexists("$(_devices_src)"); + + "_updates_stale" not => fileexists("$(_updates_src)"); + + "_updates_stale" + expression => isgreaterthan("$(_updates_age)", "$(_updates_ttl)"), + if => fileexists("$(_updates_src)"); + + # Force refresh after a reboot: a pre-boot cache may still list + # devices whose updates have been activated by the boot, and + # consumers (manage-fwupd) would otherwise repeatedly re-apply + # them and re-trigger reboots. + "_updates_stale" + expression => islessthan("$(_updates_mtime)", "$(_boot_mtime)"), + if => fileexists("$(_updates_src)"); + + "_security_stale" not => fileexists("$(_security_src)"); + + "_security_stale" + expression => isgreaterthan("$(_security_age)", "$(_security_ttl)"), + if => fileexists("$(_security_src)"); + + linux._have_devices:: + # Module-protocol cache must be rebuilt when any of the source JSON + # files is newer than the cache itself, or when the cache is missing. + "_cache_missing" not => fileexists("$(_cache)"); + + "_cache_stale_devices" + expression => isgreaterthan("$(_devices_mtime)", "$(_cache_mtime)"); + + "_cache_stale_updates" + expression => isgreaterthan("$(_updates_mtime)", "$(_cache_mtime)"), + if => "have_fwupdmgr"; + + "_cache_stale_security" + expression => isgreaterthan("$(_security_mtime)", "$(_cache_mtime)"), + if => "have_fwupdmgr"; + + "_rebuild_cache" + or => { + "_cache_missing", + "_cache_stale_devices", + "_cache_stale_updates", + "_cache_stale_security", + }; + + vars: + linux.have_fwupdmgr._devices_stale:: + "_devices_raw" + string => execresult("$(fwupdmgr) get-devices --json", "noshell"); + + "_devices_payload" + string => ifelse( + validjson("$(_devices_raw)", "true"), + "$(_devices_raw)", + '{"Devices":[]}' + ); + + linux.have_fwupdmgr._updates_stale:: + "_updates_raw" + string => execresult("$(fwupdmgr) get-updates --json", "noshell"); + + "_updates_payload" + string => ifelse( + validjson("$(_updates_raw)", "true"), + "$(_updates_raw)", + '{"Devices":[]}' + ); + + linux.have_fwupdmgr._security_stale:: + "_security_raw" + string => execresult("$(fwupdmgr) security --json", "noshell"); + + "_security_payload" + string => ifelse( + validjson("$(_security_raw)", "true"), + "$(_security_raw)", + '{"SecurityAttributes":[]}' + ); + + files: + linux:: + "$(_fwupd_dir)/." create => "true"; + + # The mtime is the TTL clock. content only writes when the file differs, + # so without touch an unchanged cache stays stale and fwupdmgr runs + # again on every agent run. + linux.have_fwupdmgr._devices_stale:: + "$(_devices_src)" + content => "$(_devices_payload)", + touch => "true"; + + linux.have_fwupdmgr._updates_stale:: + "$(_updates_src)" + content => "$(_updates_payload)", + touch => "true"; + + linux.have_fwupdmgr._security_stale:: + "$(_security_src)" + content => "$(_security_payload)", + touch => "true"; + + methods: + linux._have_devices._rebuild_cache:: + "render" + usebundle => inventory_fwupd_render( + "$(_devices_src)", + "$(_updates_src)", + "$(_security_src)", + "$(_template)", + "$(_cache)" + ); + + linux.have_fwupdmgr:: + # HSI rollup is small (typically <40 attributes) and computed inline. + # Emitted as direct policy variables rather than via the module-protocol + # cache because filtering objects out of a data container and reshaping + # them into a mustache-iterable list is more code than just iterating + # and tagging the few results with inventory meta. + "hsi" + usebundle => inventory_fwupd_hsi("$(_security_src)"), + if => fileexists("$(_security_src)"); + + # Per-device pending firmware update inventory, emitted natively + # from the updates JSON cache. Mirrors the HSI bundle idiom. + "updates" + usebundle => inventory_fwupd_updates("$(_updates_src)"), + if => fileexists("$(_updates_src)"); + + # Per-device firmware inventory, emitted natively from the devices + # JSON cache. Mirrors the HSI bundle idiom. + "devices" + usebundle => inventory_fwupd_devices("$(_devices_src)"), + if => fileexists("$(_devices_src)"); + + classes: + linux._have_devices:: + "_cache_was_read" if => read_module_protocol("$(_cache)"); + + linux.!have_fwupdmgr:: + # vars run before classes, so guarding the variable on !have_fwupdmgr + # directly would set it on pass 1, before that class is evaluated. + "_emit_missing_status"; + + vars: + linux._emit_missing_status:: + "fwupd_status" + string => "FWUPD_MISSING", + meta => { "inventory", "attribute_name=Firmware update status" }; + + reports: + linux._cache_was_read.verbose_mode:: + "inventory_fwupd: loaded $(inventory_fwupd_cache.fwupd_device_count) devices, status=$(inventory_fwupd_cache.fwupd_status)"; + + !linux.verbose_mode:: + "$(this.promise_filename): inventory-fwupd is Linux-only."; +} + +bundle agent inventory_fwupd_render(devices_src, updates_src, security_src, template, cache) +# @brief Render the module-protocol cache file from the JSON sources. +# +# The cache must be a single mustache render: read_module_protocol() in the +# caller then loads every inventory variable in one shot. +{ + vars: + "_template_body" string => readfile("$(template)", "inf"); + "_devices_json" data => readjson("$(devices_src)"); + + # fwupdmgr emits {} when it tracks no devices. + "_dev_count" + int => length("_devices_json[Devices]"), + if => isvariable("_devices_json[Devices]"); + + "_updates_json" + data => readjson("$(updates_src)"), + if => fileexists("$(updates_src)"); + + "_updates_count" + int => length("_updates_json[Devices]"), + if => isvariable("_updates_json[Devices]"); + + "_security_json" + data => readjson("$(security_src)"), + if => fileexists("$(security_src)"); + + # Default counters when fwupdmgr-derived data is not present. + "_dev_count" + int => "0", + if => not(isvariable("_dev_count")); + + "_updates_count" + int => "0", + if => not(isvariable("_updates_count")); + + # Status rollup + "_status" + string => ifelse( + isgreaterthan("$(_updates_count)", "0"), + "UPDATES_AVAILABLE", + isgreaterthan("$(_dev_count)", "0"), + "OK", + "NO_DEVICES" + ); + + # HSI inventory is emitted directly by inventory_fwupd_hsi rather than + # threaded through this cache, since reshaping filtered objects into a + # mustache-iterable list is more code than just tagging them in policy. + "_extra_json" string => format('{ "status": "%s" }', "$(_status)"); + "_extra" data => parsejson("$(_extra_json)"); + + # Measurements file: one key=value per line, read by + # inventory_fwupd_monitor for time-series tracking. + "_measurements_path" + string => "$(sys.statedir)/fwupd/inventory_measurements"; + + "_measurements_body" + string => format( + "firmware_devices_total=%d$(const.n)firmware_updates_available=%d", + "$(_dev_count)", + "$(_updates_count)" + ); + + # Mergedata combines device data, status counters, and optional + # extra top-level keys into a single data structure for mustache rendering. + # Note: pending-update inventory is emitted natively via + # inventory_fwupd_updates() rather than threaded through this cache, + # because Releases[0].Version cannot be accessed from mustache templates. + "_merged" data => mergedata("_devices_json", "_extra"); + + files: + "$(cache)" + create => "true", + template_method => "inline_mustache", + edit_template_string => "$(_template_body)", + template_data => @(_merged); + + "$(_measurements_path)" content => "$(_measurements_body)"; +} + +bundle agent inventory_fwupd_hsi(security_src) +# @brief Surface fwupd Host Security Identifier (HSI) attributes and +# compute the rolled-up HSI level. +# +# Emits: +# - "Firmware HSI failing" and "Firmware HSI passing", slists of +# "L: " check names split by outcome. +# - A count of failing attributes. +# - The rolled-up HSI level (e.g. "HSI:3"). +# +# The rollup mirrors fwupd's own algorithm: walk levels 1-4 sequentially; +# at each level, if any non-obsoleted attribute fails, stop — the result +# is the highest level that had at least one success before the walk +# stopped. Empty levels (no attributes) are skipped without advancing +# the level counter. +# +# An attribute is failing when its HsiResult differs from HsiResultSuccess. +{ + classes: + "inventory_fwupd_hsi_failing_$(_idxes)" + not => strcmp("$(_result[$(_idxes)])", "$(_success[$(_idxes)])"); + + # Per-level failure/success classes for the HSI rollup. + # If ANY attribute at level N fails, _hsi_LN_fail is defined. + # If ANY attribute at level N passes, _hsi_LN_pass is defined. + "_hsi_L$(_level[$(_idxes)])_fail" + expression => "inventory_fwupd_hsi_failing_$(_idxes)"; + + "_hsi_L$(_level[$(_idxes)])_pass" + not => "inventory_fwupd_hsi_failing_$(_idxes)"; + + vars: + "_sec" data => readjson("$(security_src)"); + "_idxes" slist => getindices("_sec[SecurityAttributes]"); + "_name[$(_idxes)]" string => "$(_sec[SecurityAttributes][$(_idxes)][Name])"; + + "_level[$(_idxes)]" + string => "$(_sec[SecurityAttributes][$(_idxes)][HsiLevel])"; + + # fwupd omits HsiLevel on runtime-issue checks (Linux kernel, UEFI db, + # ...). Default those to L0, otherwise the unresolved level skips + # every promise that builds on it and the check vanishes entirely. + "_level[$(_idxes)]" + string => "0", + unless => isvariable("_level[$(_idxes)]"); + + "_result[$(_idxes)]" + string => "$(_sec[SecurityAttributes][$(_idxes)][HsiResult])"; + + "_success[$(_idxes)]" + string => "$(_sec[SecurityAttributes][$(_idxes)][HsiResultSuccess])"; + + "_status[$(_idxes)]" + string => ifelse( + strcmp("$(_result[$(_idxes)])", "$(_success[$(_idxes)])"), + "PASS", + "FAIL" + ); + + # Normalized name for per-attribute inventory: the CSME version + # attribute has a dynamic Name containing the firmware version + # (e.g. "csme v0:16.1.40.2765") which would create a different + # inventory attribute on every host. Normalize it to a fixed name + # so compliance conditions can reference it statically. + "_inv_name[$(_idxes)]" + string => ifelse( + regcmp("csme v.*", "$(_name[$(_idxes)])"), + "CSME version", + "$(_name[$(_idxes)])" + ); + + # Normalized level: fwupd marks some attributes as HsiLevel 0 with a + # "runtime-issue" flag even though they contribute to scored HSI + # levels. Map them to their HSI spec level so list items are stable. + # UEFI secure boot (org.fwupd.hsi.Uefi.SecureBoot) → HSI:1 + # CET OS Support (org.fwupd.hsi.IntelCet.Active) → HSI:3 + "_inv_level[$(_idxes)]" + string => ifelse( + strcmp("$(_name[$(_idxes)])", "UEFI secure boot"), + "1", + strcmp("$(_name[$(_idxes)])", "CET OS Support"), + "3", + "$(_level[$(_idxes)])" + ); + + # Check names split by outcome into two lists, rather than one + # inventory attribute per check. + # + # Inventory attribute names are fleet wide: an attribute any host + # defines is offered as a column for every host. fwupd's check set + # is hardware dependent, so one attribute per check grew that list + # with every distinct chipset in the fleet while each host filled in + # only its own subset. Two lists keep this module at two stable + # attribute names regardless of the hardware, and carry the same + # information: a check in neither list is not applicable to this + # host. + # + # The level prefix disambiguates fwupd's two distinct checks both + # named "Platform debugging" (L1 Intel DCI, L2 debug locked). + "_failing_name[$(_idxes)]" + string => "L$(_inv_level[$(_idxes)]): $(_inv_name[$(_idxes)])", + if => strcmp("$(_status[$(_idxes)])", "FAIL"); + + "_passing_name[$(_idxes)]" + string => "L$(_inv_level[$(_idxes)]): $(_inv_name[$(_idxes)])", + if => strcmp("$(_status[$(_idxes)])", "PASS"); + + "fwupd_hsi_failing" + slist => getvalues("_failing_name"), + meta => { "inventory", "attribute_name=Firmware HSI failing" }; + + "fwupd_hsi_passing" + slist => getvalues("_passing_name"), + meta => { "inventory", "attribute_name=Firmware HSI passing" }; + + "fwupd_hsi_failing_count" + int => countclassesmatching("inventory_fwupd_hsi_failing_[0-9]+"); + + # Rolled-up HSI level: highest level with a pass where no level + # at or below it has a failure. Checks highest first so the + # first match wins. Mirrors fwupd's fu_security_attrs_calculate_hsi(). + "fwupd_hsi_level" + string => ifelse( + and( + "_hsi_L4_pass", + not("_hsi_L1_fail"), + not("_hsi_L2_fail"), + not("_hsi_L3_fail"), + not("_hsi_L4_fail") + ), + "HSI:4", + and( + "_hsi_L3_pass", + not("_hsi_L1_fail"), + not("_hsi_L2_fail"), + not("_hsi_L3_fail") + ), + "HSI:3", + and("_hsi_L2_pass", not("_hsi_L1_fail"), not("_hsi_L2_fail")), + "HSI:2", + and("_hsi_L1_pass", not("_hsi_L1_fail")), + "HSI:1", + "HSI:0" + ), + meta => { "inventory", "attribute_name=Firmware HSI level" }; + + files: + # Write HSI failing count to its own measurements file so + # cf-monitord can track it as a time-series metric. + "$(sys.statedir)/fwupd/measurements_hsi" + content => "firmware_hsi_failing_attributes=$(fwupd_hsi_failing_count)", + if => isvariable("fwupd_hsi_failing_count"); +} + +bundle agent inventory_fwupd_updates(updates_src) +# @brief Surface per-device pending firmware update inventory. +# +# Reads the updates JSON cache and emits one inventory entry per device +# that has a pending update. Format: +# "Device Name: current_version -> new_version" +# +# Unlike the HSI bundle, devices here are iterated from the Devices array +# in fwupd_inventory_updates.json rather than SecurityAttributes. +# Releases[0].Version is the new version (mustache templates can't index +# into JSON arrays, so we flatten it natively in CFEngine). +{ + vars: + "_upd" data => readjson("$(updates_src)"); + "_idxes" slist => getindices("_upd[Devices]"); + "_did[$(_idxes)]" string => "$(_upd[Devices][$(_idxes)][DeviceId])"; + "_name[$(_idxes)]" string => "$(_upd[Devices][$(_idxes)][Name])"; + "_oldv[$(_idxes)]" string => "$(_upd[Devices][$(_idxes)][Version])"; + + "_newv[$(_idxes)]" + string => "$(_upd[Devices][$(_idxes)][Releases][0][Version])"; + + "fwupd_dev_pending[$(_did[$(_idxes)])]" + string => "$(_name[$(_idxes)]): $(_oldv[$(_idxes)]) -> $(_newv[$(_idxes)])", + meta => { "inventory", "attribute_name=Firmware device pending update" }; +} + +bundle agent inventory_fwupd_devices(devices_src) +# @brief Surface per-device firmware inventory, one entry per non-empty +# device. Format: "Name | Vendor | vVersion | [Plugin]" +# +# Reads the devices JSON cache and emits one inventory entry per device +# that has a non-empty Name. Emits via indexed arrays (same idiom as +# inventory_fwupd_hsi / inventory_fwupd_updates). Devices with empty +# Name silently skip via the if => not(strcmp(...)) guard. +{ + vars: + "_dev" data => readjson("$(devices_src)"); + "_idxes" slist => getindices("_dev[Devices]"); + "_did[$(_idxes)]" string => "$(_dev[Devices][$(_idxes)][DeviceId])"; + "_name[$(_idxes)]" string => "$(_dev[Devices][$(_idxes)][Name])"; + "_vend[$(_idxes)]" string => "$(_dev[Devices][$(_idxes)][Vendor])"; + "_ver[$(_idxes)]" string => "$(_dev[Devices][$(_idxes)][Version])"; + "_plug[$(_idxes)]" string => "$(_dev[Devices][$(_idxes)][Plugin])"; + + "fwupd_dev[$(_did[$(_idxes)])]" + string => "$(_name[$(_idxes)]) | $(_vend[$(_idxes)]) | v$(_ver[$(_idxes)]) | [$(_plug[$(_idxes)])]", + meta => { "inventory", "attribute_name=Firmware devices" }, + if => not(strcmp("$(_name[$(_idxes)])", "")); +} + +bundle monitor inventory_fwupd_monitor +# @brief Track firmware metrics as time-series measurements. +# +# Reads key=value files written by inventory_fwupd_render and +# inventory_fwupd_hsi and emits three measurements: +# - firmware_devices_total (devices fwupd is tracking) +# - firmware_updates_available (devices with a pending update) +# - firmware_hsi_failing (HSI security checks failing) +{ + measurements: + linux:: + "$(sys.statedir)/fwupd/inventory_measurements" + handle => "firmware_devices_total", + stream_type => "file", + data_type => "int", + history_type => "weekly", + units => "devices", + match_value => inventory_fwupd_kv("firmware_devices_total"), + if => fileexists("$(sys.statedir)/fwupd/inventory_measurements"); + + "$(sys.statedir)/fwupd/inventory_measurements" + handle => "firmware_updates_available", + stream_type => "file", + data_type => "int", + history_type => "weekly", + units => "devices", + match_value => inventory_fwupd_kv("firmware_updates_available"), + if => fileexists("$(sys.statedir)/fwupd/inventory_measurements"); + + "$(sys.statedir)/fwupd/measurements_hsi" + handle => "firmware_hsi_failing", + stream_type => "file", + data_type => "int", + history_type => "weekly", + units => "checks", + match_value => inventory_fwupd_kv("firmware_hsi_failing_attributes"), + if => fileexists("$(sys.statedir)/fwupd/measurements_hsi"); +} + +body match_value inventory_fwupd_kv(key) +{ + select_line_matching => "$(key)=.*"; + extraction_regex => "$(key)=(\d+)"; +} + +bundle agent __main__ +{ + methods: + "main" usebundle => inventory_fwupd_main; +} diff --git a/inventory/inventory-smartctl/README.md b/inventory/inventory-smartctl/README.md new file mode 100644 index 00000000..214f8eaf --- /dev/null +++ b/inventory/inventory-smartctl/README.md @@ -0,0 +1,138 @@ +Inventory module for collecting SMART drive health, temperature, and wear data via smartctl. + +## Description + +This module collects S.M.A.R.T. (Self-Monitoring, Analysis and Reporting Technology) data from storage devices and exposes it as inventory attributes in CFEngine Mission Portal. +It provides a rolled-up status for fleet-wide filtering (`OK`, `DEGRADED`, `SMARTCTL_MISSING`) along with per-drive health, temperature, power-on hours, and NVMe-specific metrics. + +SMART data helps predict drive failures before they occur and provides visibility into storage device health across your infrastructure. + +## Requirements + +- **Platform:** Linux only (currently) +- **Binary:** `smartctl` from smartmontools package (version 7.0+ for JSON support) +- **Permissions:** Requires root to read SMART data from devices + +### Installation + +Add to your policy via cfbs: + +```bash +cfbs add inventory-smartctl +cfbs install +``` + +Or include directly in your policy: + +```cfengine +bundle agent main +{ + methods: + "smartctl" usebundle => inventory_smartctl:main; +} +``` + +## Inventory Attributes + +The following attributes are exposed in Mission Portal: + +### Overall Status + +- **SMART status** - Rolled-up health across all drives + - Values: `OK`, `DEGRADED`, `SMARTCTL_MISSING` + - `OK`: All detected drives report PASSED + - `DEGRADED`: One or more drives report FAILED + - `SMARTCTL_MISSING`: smartctl is not installed on the system + - Use for fleet-wide filtering and alerting in Mission Portal + +### Per-Drive Attributes (all drive types) + +- **SMART drive health** - Per-drive health status + - Values: `PASSED`, `FAILED` + - Example: `/dev/sda: PASSED`, `/dev/nvme0: FAILED` + - A FAILED status indicates the drive is predicting imminent failure + +- **SMART drive model** - Drive model identifier + - Example: `/dev/sda: Samsung SSD 870 EVO` + +- **SMART drive temperatures (C)** - Current temperature in Celsius + - Example: `/dev/sda: 35 C` + - Note: Not available for virtual disks + +- **SMART drive power-on hours** - Cumulative runtime in hours + - Example: `/dev/sda: 8742 h` + - Useful for tracking drive age and warranty coverage + +### NVMe-Specific Attributes + +- **SMART NVMe available spare** - Remaining spare blocks (%) + - Example: `/dev/nvme0: 100%` + - Low values (<10%) indicate wear approaching end of life + +- **SMART NVMe percentage used** - Drive life consumed (%) + - Example: `/dev/nvme0: 5%` + - Based on manufacturer's endurance rating + +- **SMART NVMe media errors** - Uncorrectable media errors count + - Example: `/dev/nvme0: 0` + - Any non-zero value indicates data integrity issues + +### Alert Attributes + +- **SMART failed drives** - List of drives with FAILED health status + - Only present when one or more drives are failing + - Use for alerting and automated response + +## Troubleshooting + +### SMART status shows SMARTCTL_MISSING + +The `SMART status` attribute reports `SMARTCTL_MISSING` when smartctl is not installed. To resolve: + +**Install smartmontools package:** + +```sh +# Debian/Ubuntu +apt-get install smartmontools + +# RHEL/CentOS/Fedora +yum install smartmontools + +# SUSE +zypper install smartmontools +``` + +**Verify installation:** + +```sh +command -v smartctl +smartctl --version +``` + +### No inventory data appears + +If smartctl is installed but no data appears: + +**Check if drives are detected:** + +```sh +smartctl --scan +``` + +**Check cache files:** + +```sh +ls -lh /var/cfengine/state/inventory_smartctl_*.json +``` + +**Run with verbose mode:** + +```sh +cf-agent -Kvf ./policy.cf +``` + +## See Also + +- [CFEngine inventory tutorial](https://docs.cfengine.com/docs/lts/examples/tutorials/custom_inventory/) +- [CFEngine Masterfiles inventory policy](https://docs.cfengine.com/docs/lts/reference/masterfiles-policy-framework/inventory/) +- [smartmontools documentation](https://www.smartmontools.org/) diff --git a/inventory/inventory-smartctl/cfbs.json b/inventory/inventory-smartctl/cfbs.json new file mode 100644 index 00000000..2babe4e9 --- /dev/null +++ b/inventory/inventory-smartctl/cfbs.json @@ -0,0 +1,9 @@ +{ + "name": "inventory-smartctl", + "description": "Inventory SMART drive health, temperature, and wear data", + "tags": ["inventory", "monitoring", "hardware", "storage", "smartctl"], + "version": "0.1.0", + "steps": ["copy ./policy.cf services/inventory/smartctl.cf"], + "dependencies": [], + "subdirectory": "inventory/inventory-smartctl" +} diff --git a/inventory/inventory-smartctl/policy.cf b/inventory/inventory-smartctl/policy.cf new file mode 100644 index 00000000..2358b111 --- /dev/null +++ b/inventory/inventory-smartctl/policy.cf @@ -0,0 +1,250 @@ +body file control +{ + namespace => "inventory_smartctl"; +} + +bundle agent main +# @brief Inventory SMART drive health, temperature, and wear data via smartctl JSON +# +# Requires smartmontools >= 7.0 (for JSON output support). +# Runs on Linux only; silently no-ops on other platforms. +# +# Attributes exposed in Mission Portal: +# @inventory SMART status - OK, DEGRADED, or SMARTCTL_MISSING +# @inventory SMART drive health - Per-drive PASSED/FAILED +# @inventory SMART drive model - Drive model per device +# @inventory SMART drive temperatures - Current temperature in Celsius +# @inventory SMART drive power-on hours - Cumulative runtime in hours +# @inventory SMART NVMe available spare - Remaining spare blocks (%), NVMe only +# @inventory SMART NVMe percentage used - Drive life consumed (%), NVMe only +# @inventory SMART NVMe media errors - Uncorrectable media errors, NVMe only +# @inventory SMART failed drives - Only present on hosts with a failing drive +{ + vars: + linux:: + "_smartctl" + string => ifelse( + fileexists("/usr/sbin/smartctl"), + "/usr/sbin/smartctl", + fileexists("/sbin/smartctl"), + "/sbin/smartctl", + "/usr/sbin/smartctl" + # default fallback + ); + + "_sdir" string => "$(sys.statedir)"; + + "_cache_ttl" + string => "3600", + comment => "3600 seconds is 1 hour"; + + linux._have_smartctl:: + # Enumerate drives - extract first field from each line of smartctl --scan + "_scan_lines" + slist => splitstring( + execresult("$(_smartctl) --scan 2>/dev/null", "useshell"), "\n", 32 + ); + + "_drives" + slist => maplist( + regex_replace("$(this)", "^(\S+).*", "\1", ""), "_scan_lines" + ); + + "_id[${_drives}]" string => canonify("${_drives}"); + + "_cache[${_drives}]" + string => "$(_sdir)/inventory_smartctl_${_id[${_drives}]}.json"; + + # Rolled-up status: OK or DEGRADED (SMARTCTL_MISSING when smartctl absent) + "smartctl_status" + string => "DEGRADED", + meta => { "inventory", "attribute_name=SMART status" }, + if => some(".*", "failed_drives"); + + "smartctl_status" + string => "OK", + meta => { "inventory", "attribute_name=SMART status" }, + if => not(isvariable("failed_drives")); + + linux.!_have_smartctl:: + "smartctl_status" + string => "SMARTCTL_MISSING", + meta => { "inventory", "attribute_name=SMART status" }; + + linux._have_smartctl:: + # Inventory attributes (visible in Mission Portal) + # Each array element is a separate variable in inventory, avoiding + # the ~4K truncation limit that affects slists on hosts with many drives. + "drive_health[${_drives}]" + string => "${_drives}: ${_d_${_id[${_drives}]}[health]}", + meta => { "inventory", "attribute_name=SMART drive health" }, + if => isvariable("_d_${_id[${_drives}]}[health]"); + + "drive_model[${_drives}]" + string => "${_drives}: ${_d_${_id[${_drives}]}[model]}", + meta => { "inventory", "attribute_name=SMART drive model" }, + if => isvariable("_d_${_id[${_drives}]}[model]"); + + "drive_temperatures[${_drives}]" + string => "${_drives}: ${_d_${_id[${_drives}]}[temp]} C", + meta => { "inventory", "attribute_name=SMART drive temperatures (C)" }, + if => isvariable("_d_${_id[${_drives}]}[temp]"); + + "drive_power_on_hours[${_drives}]" + string => "${_drives}: ${_d_${_id[${_drives}]}[hours]} h", + meta => { "inventory", "attribute_name=SMART drive power-on hours" }, + if => isvariable("_d_${_id[${_drives}]}[hours]"); + + "nvme_available_spare[${_drives}]" + string => "${_drives}: ${_d_${_id[${_drives}]}[nvme_spare]}%", + meta => { "inventory", "attribute_name=SMART NVMe available spare" }, + if => isvariable("_d_${_id[${_drives}]}[nvme_spare]"); + + "nvme_percentage_used[${_drives}]" + string => "${_drives}: ${_d_${_id[${_drives}]}[nvme_pct_used]}%", + meta => { "inventory", "attribute_name=SMART NVMe percentage used" }, + if => isvariable("_d_${_id[${_drives}]}[nvme_pct_used]"); + + "nvme_media_errors[${_drives}]" + string => "${_drives}: ${_d_${_id[${_drives}]}[nvme_media_errors]}", + meta => { "inventory", "attribute_name=SMART NVMe media errors" }, + if => isvariable("_d_${_id[${_drives}]}[nvme_media_errors]"); + + "failed_drives[${_drives}]" + string => "${_drives}", + meta => { "inventory", "attribute_name=SMART failed drives" }, + if => strcmp("${_d_${_id[${_drives}]}[health]}", "FAILED"); + + classes: + linux:: + "_have_smartctl" expression => isexecutable("$(_smartctl)"); + + # Cache file is missing - needs refresh + "_cache_missing_${_id[${_drives}]}" + not => fileexists("${_cache[${_drives}]}"); + + # Cache file is stale - needs refresh + "_cache_stale_${_id[${_drives}]}" + expression => isgreaterthan( + eval("$(sys.systime) - $(filestat(${_cache[${_drives}]}, mtime))"), + "$(_cache_ttl)" + ), + if => fileexists("${_cache[${_drives}]}"); + + # Refresh if missing or stale + "_refresh_${_id[${_drives}]}" + or => { + "_cache_missing_${_id[${_drives}]}", + "_cache_stale_${_id[${_drives}]}", + }; + + files: + linux._have_smartctl:: + "${_cache[${_drives}]}" + content => execresult( + "$(_smartctl) -j -a ${_drives}", "noshell", "stdout" + ), + if => "_refresh_${_id[${_drives}]}"; + + methods: + linux._have_smartctl:: + # Call parsing bundle for each drive (only when cache exists) + "parse_${_id[${_drives}]}" + usebundle => parse("${_drives}", "${_cache[${_drives}]}"), + useresult => "_d_${_id[${_drives}]}", + if => fileexists("${_cache[${_drives}]}"); + + reports: + linux._have_smartctl.verbose_mode:: + "inventory_smartctl: monitoring ${_drives}"; + + "inventory_smartctl: ${_drives} health=${_d_${_id[${_drives}]}[health]}" + if => isvariable("_d_${_id[${_drives}]}[health]"); + + !linux.verbose_mode:: + "$(this.promise_filename): inventory_smartctl is Linux-only."; +} + +bundle agent parse(drive, cache_file) +# @brief Parse smartctl JSON and return key metrics via bundle_return_value_index +{ + vars: + "_json" data => readjson("$(cache_file)"); + + # Extract metrics directly from JSON + "_health" + string => ifelse( + strcmp("${_json[smart_status][passed]}", "true"), "PASSED", "FAILED" + ), + if => isvariable("_json[smart_status][passed]"); + + "_model" + string => "${_json[model_name]}", + if => isvariable("_json[model_name]"); + + "_temp" + string => "${_json[temperature][current]}", + if => isvariable("_json[temperature][current]"); + + "_hours" + string => "${_json[power_on_time][hours]}", + if => isvariable("_json[power_on_time][hours]"); + + "_nvme_spare" + string => "${_json[nvme_smart_health_information_log][available_spare]}", + if => isvariable( + "_json[nvme_smart_health_information_log][available_spare]" + ); + + "_nvme_pct_used" + string => "${_json[nvme_smart_health_information_log][percentage_used]}", + if => isvariable( + "_json[nvme_smart_health_information_log][percentage_used]" + ); + + "_nvme_media_errors" + string => "${_json[nvme_smart_health_information_log][media_errors]}", + if => isvariable( + "_json[nvme_smart_health_information_log][media_errors]" + ); + + reports: + "$(_health)" + bundle_return_value_index => "health", + if => isvariable("_health"); + + "$(_model)" + bundle_return_value_index => "model", + if => isvariable("_model"); + + "$(_temp)" + bundle_return_value_index => "temp", + if => isvariable("_temp"); + + "$(_hours)" + bundle_return_value_index => "hours", + if => isvariable("_hours"); + + "$(_nvme_spare)" + bundle_return_value_index => "nvme_spare", + if => isvariable("_nvme_spare"); + + "$(_nvme_pct_used)" + bundle_return_value_index => "nvme_pct_used", + if => isvariable("_nvme_pct_used"); + + "$(_nvme_media_errors)" + bundle_return_value_index => "nvme_media_errors", + if => isvariable("_nvme_media_errors"); +} + +body file control +{ + namespace => "default"; +} + +bundle agent __main__ +{ + methods: + "inventory_smartctl:main"; +} diff --git a/inventory/inventory-windows-services/inventory-windows-services.cf b/inventory/inventory-windows-services/inventory-windows-services.cf new file mode 100644 index 00000000..d37ff9e4 --- /dev/null +++ b/inventory/inventory-windows-services/inventory-windows-services.cf @@ -0,0 +1,25 @@ +bundle agent inventory_windows_services_running +# @brief Inventory Windows services that are running +# @inventory Windows services running +{ + vars: + windows:: + "_cache" string => "$(sys.statedir)/windows-services-running.json"; + "_data" data => readjson("$(_cache)"); + "_indices" slist => getindices(_data); + + "i[$(_indices)]" + string => "$(_data[$(_indices)][DisplayName])", + meta => { "inventory", "attribute_name=Windows services running" }; + + commands: + windows:: + "Get-Service | Where-Object {$_.Status -eq 'Running'} | ConvertTo-Json | Set-Content -Path '${_cache}'" + contain => powershell; +} + +bundle agent __main__ +{ + methods: + "inventory_windows_services_running"; +} diff --git a/libraries/python/README.md b/libraries/python/README.md new file mode 100644 index 00000000..5fa9ecac --- /dev/null +++ b/libraries/python/README.md @@ -0,0 +1,16 @@ +A library for CFEngine custom promise types implemented in Python. +It provides the `PromiseModule` base class and many helper values and mechanisms that facilitates creation of custom promises. + +If you'd like to develop new promise types (or other modules) for CFEngine, we recommend finishing our getting started tutorial: + +https://docs.cfengine.com/docs/3.21/getting-started.html + +(The last part shows implementing your first module). + +If you feel comfortable with using CFEngine and writing Python code already, there is a template project you can just copy and start editing: + +https://github.com/cfengine/promise-type-template + +For more detailed information about how custom promise types work, see our documentation: + +https://docs.cfengine.com/docs/3.21/reference-promise-types-custom.html diff --git a/libraries/python/README.org b/libraries/python/README.org deleted file mode 100644 index ba9cb344..00000000 --- a/libraries/python/README.org +++ /dev/null @@ -1,7 +0,0 @@ -* CFEngine Python promise types library - -A library for CFEngine custom promise types implemented in Python. It provides -the =PromiseModule= base class and many helper values and mechanisms that -facilitates creation of custom promises. - -See [[https://github.com/cfengine/modules/blob/master/examples/git-using-lib/git_using_lib.py][examples/git-using-lib]] for an example of how this library can be used. diff --git a/libraries/python/cfengine.py b/libraries/python/cfengine_module_library.py similarity index 85% rename from libraries/python/cfengine.py rename to libraries/python/cfengine_module_library.py index 8f45447b..3aa02eba 100644 --- a/libraries/python/cfengine.py +++ b/libraries/python/cfengine_module_library.py @@ -1,8 +1,27 @@ +""" +CFEngine module library + +This library can be used to implement CFEngine modules in python. +Currently, this is for implementing custom promise types, +but it might be expanded to other types of modules in the future, +for example custom functions. + +This library is available as a module in CFEngine Build: + +https://build.cfengine.com/modules/library-for-promise-types-in-python/ + +CFEngine Build version: 0.0.0 + +(If the version number is all zeroes, you are looking at the source. It will +be replaced in a build step when you run cfbs build). +""" + import sys import json import traceback from copy import copy from collections import OrderedDict +from typing import Any _LOG_LEVELS = { level: idx @@ -39,12 +58,19 @@ def _put_response(data, file, record_file=None): record_file.write("> \n") -def _would_log(level_set, msg_level): +def _should_send_log(level_set, msg_level): if msg_level not in _LOG_LEVELS: # uknown level, assume it would be logged return True - return _LOG_LEVELS[msg_level] <= _LOG_LEVELS[level_set] + # info: log messages are special because they report changes done in promise + # evaluation which is important not only for showing to the user, but also + # for auditing/changelog and all modules are required to send info: messages + # for all REPAIRED promises. A similar logic applies to errors and warnings, + # IOW, anything at or above the info level. + return (_LOG_LEVELS[msg_level] <= _LOG_LEVELS["info"]) or ( + _LOG_LEVELS[msg_level] <= _LOG_LEVELS[level_set] + ) def _cfengine_type(typing): @@ -65,12 +91,25 @@ class AttributeObject(object): def __init__(self, d): for key, value in d.items(): setattr(self, key, value) + + # Python only calls __getattr__ as a fallback when normal attribute + # lookup (__getattribute__) has already failed, so attributes set via + # setattr() in __init__ are never affected. The -> Any return type + # tells pyright that dynamic attribute access is valid. + def __getattr__(self, name) -> Any: + raise AttributeError( + "'{}' object has no attribute '{}'".format( + self.__class__.__qualname__, name + ) + ) + def __repr__(self): return "{}({})".format( self.__class__.__qualname__, - ", ".join("{}={!r}".format(k, v) for k, v in self.__dict__.items()) + ", ".join("{}={!r}".format(k, v) for k, v in self.__dict__.items()), ) + class ValidationError(Exception): def __init__(self, message): self.message = message @@ -101,7 +140,7 @@ class Result: class PromiseModule: def __init__( - self, name="default_module_name", version="0.0.1", record_file_path=None + self, name="default_module_name", version="0.0.0", record_file_path=None ): self.name = name self.version = version @@ -199,6 +238,8 @@ def _handle_request(self, request): "debug", ] + promiser = None + attributes = {} if operation in ["validate_promise", "evaluate_promise"]: promiser = request["promiser"] attributes = request.get("attributes", {}) @@ -326,10 +367,10 @@ def _handle_init(self): _put_response(self._response, self._out, self._record_file) def _handle_validate(self, promiser, attributes, request): - meta = {"promise_type": request.get("promise_type")} + metadata = {"promise_type": request.get("promise_type")} try: - self.validate_attributes(promiser, attributes, meta) - returned = self.validate_promise(promiser, attributes, meta) + self.validate_attributes(promiser, attributes, metadata) + returned = self.validate_promise(promiser, attributes, metadata) if returned is None: # Good, expected self._result = Result.VALID @@ -370,12 +411,14 @@ def _handle_validate(self, promiser, attributes, request): def _handle_evaluate(self, promiser, attributes, request): self._result_classes = None - meta = {"promise_type": request.get("promise_type")} + metadata = {"promise_type": request.get("promise_type")} try: - results = self.evaluate_promise(promiser, attributes, meta) + results = self.evaluate_promise(promiser, attributes, metadata) + + assert results is not None # Most likely someone forgot to return something # evaluate_promise should return either a result or a (result, result_classes) pair - if type(results) == str: + if isinstance(results, str): self._result = results else: assert len(results) == 2 @@ -383,7 +426,9 @@ def _handle_evaluate(self, promiser, attributes, request): self._result_classes = results[1] except Exception as e: self.log_critical( - "{error_type}: {error}".format(error_type=type(e).__name__, error=e) + "{error_type}: {error} (Bug in python promise type module, run with --debug for traceback)".format( + error_type=type(e).__name__, error=e + ) ) self._add_traceback_to_response() self._result = Result.ERROR @@ -398,7 +443,7 @@ def _handle_terminate(self): sys.exit(0) def _log(self, level, message): - if self._log_level is not None and not _would_log(self._log_level, level): + if self._log_level is not None and not _should_send_log(self._log_level, level): return # Message can be str or an object which implements __str__() @@ -448,16 +493,16 @@ def prepare_promiser_and_attributes(self, promiser, attributes): """Override if you want to modify promiser or attributes before validate or evaluate""" return (promiser, attributes) - def validate_attributes(self, promiser, attributes, meta): + def validate_attributes(self, promiser, attributes, metadata): """Override this if you want to prevent automatic validation""" return self._validate_attributes(promiser, attributes) - def validate_promise(self, promiser, attributes, meta): + def validate_promise(self, promiser, attributes, metadata): """Must override this or use validation through self.add_attribute()""" if not self._has_validation_attributes: raise NotImplementedError("Promise module must implement validate_promise") - def evaluate_promise(self, promiser, attributes, meta): + def evaluate_promise(self, promiser, attributes, metadata): raise NotImplementedError("Promise module must implement evaluate_promise") def protocol_terminate(self): diff --git a/management/allow-all-hosts/README.md b/management/allow-all-hosts/README.md new file mode 100644 index 00000000..96e571bd --- /dev/null +++ b/management/allow-all-hosts/README.md @@ -0,0 +1,33 @@ +By default, CFEngine only accepts incoming connections from IP addresses on the same network (`/16` subnet). +This module changes the setting to allow all IP addresses. + +**Warning:** This module is intended to make testing / demonstrations easier. +It should **not** be used for production setups. + +## Details + +Internally, this changes the value of the `default:def.control_server_allowconnects` and `default:def.acl` variables. +The 2 variables have the same default values, but control slightly different things: + +* `default:def.control_server_allowconnects` - Works similar to a firewall. Controls which IP addresses are allowed to connect to the `cf-serverd`. +* `default:def.acl` - Controls access to files on the server, i.e. which IP addresses are allowed to fetch files, most notably the policy set from `/var/cfengine/masterfiles` on the hub. + +Advanced users might want to configure each of these variables individually, or even customize specific access for specific folders. + +You can achieve the same by editing `/var/cfengine/masterfiles/def.json`: + +``` +{ + "variables": { + "default:def.control_server_allowconnects": ["0.0.0.0/0", "::/0"], + "default:def.acl": ["0.0.0.0/0", "::/0"] + } +} +``` + +You can also edit these variables using the CMDB feature in Mission Portal. + +**Tip:** You can omit the first variable, it defaults to the value of `default:def.acl`, when not specified. + +**Note:** The variables and defaults mentioned here are for the default CFEngine policy set (MPF). +If you are only using the CFEngine binaries, not the default policy, these variables don't do anything special. diff --git a/management/allow-all-hosts/def.json b/management/allow-all-hosts/def.json new file mode 100644 index 00000000..a9da4255 --- /dev/null +++ b/management/allow-all-hosts/def.json @@ -0,0 +1,6 @@ +{ + "variables": { + "default:def.control_server_allowconnects": ["0.0.0.0/0", "::/0"], + "default:def.acl": ["0.0.0.0/0", "::/0"] + } +} diff --git a/management/autorun-bundles/README.md b/management/autorun-bundles/README.md new file mode 100644 index 00000000..004710c7 --- /dev/null +++ b/management/autorun-bundles/README.md @@ -0,0 +1,3 @@ +# Enable autorun bundles + +Simple module to enable automatic execution of bundles tagged with `autorun`, using `def.json`. diff --git a/management/autorun-bundles/def.json b/management/autorun-bundles/def.json new file mode 100644 index 00000000..385c3e7e --- /dev/null +++ b/management/autorun-bundles/def.json @@ -0,0 +1,3 @@ +{ + "classes": { "services_autorun_bundles": ["any"] } +} diff --git a/management/autorun-inputs/README.md b/management/autorun-inputs/README.md new file mode 100644 index 00000000..4e851b73 --- /dev/null +++ b/management/autorun-inputs/README.md @@ -0,0 +1,3 @@ +# Enable autorun inputs + +Simple module to enable automatic loading of policy files that reside in `services/autorun/`, using `def.json`. diff --git a/management/autorun-inputs/def.json b/management/autorun-inputs/def.json new file mode 100644 index 00000000..c44ff2a1 --- /dev/null +++ b/management/autorun-inputs/def.json @@ -0,0 +1,3 @@ +{ + "classes": { "services_autorun_inputs": ["any"] } +} diff --git a/management/autorun/README.md b/management/autorun/README.md index e96fc850..5f143853 100644 --- a/management/autorun/README.md +++ b/management/autorun/README.md @@ -1,3 +1,26 @@ -# Enable autorun +This module enables autorun functionality so you can add policy files to `services/autorun` and tag bundles with `autorun` causing them to be automatically discovered and run. -Simple module to enable autorun functionality, using def.json. +A policy file you add to `services/autorun` could look like this: + +```cfengine3 +bundle agent my_example +{ + meta: + "tags" + slist => { "autorun" }; + reports: + "Hello, world!" +} +``` + +With autorun enabled, the policy file would be parsed, and the bundle evaluated without editing any of your existing files / policy. + +It uses the augments file to achieve this, it is the same as editing your `def.json` file like this: + +```json +{ + "classes": { + "services_autorun": ["any"] + } +} +``` diff --git a/management/autorun/def.json b/management/autorun/def.json index d2090d26..193a0b30 100644 --- a/management/autorun/def.json +++ b/management/autorun/def.json @@ -1,5 +1,3 @@ { - "classes": { - "services_autorun": ["any"] - } + "classes": { "services_autorun": ["any"] } } diff --git a/management/command-dispatcher/README.md b/management/command-dispatcher/README.md new file mode 100644 index 00000000..15e0e2b3 --- /dev/null +++ b/management/command-dispatcher/README.md @@ -0,0 +1,39 @@ +This module enables the running of shell commands based on input from the Mission-Portal's build page or `cfbs input`. + +Internally these commands are ran using the `"useshell"`-promise based on the `condition`-variable. + +**Note:** +The commands are dispatched with root-privilege (uid=0) and a timeout-window of 300 seconds. + +--- + +**Usage:** + +* `command` - The command to run. + +* `condition` - Condition for running. Use a class expression (e.g., `linux|bsd`). Defaults to `"any"` + +* `ifelapsed` - Number of minutes between assessments. Defaults to 5 minutes. + +E.g. +```json +... +{ +"command": "echo \"Hello World\"", +"condition": "linux", +"ifelapsed": "5" +}, +... +``` +Would echo "Hello world" on every linux device with 5 minute intervals. + +--- + +## Contribute + +Feel free to open pull requests to expand this documentation, add features or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://tracker.mender.io/issues/). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/management/command-dispatcher/main.cf b/management/command-dispatcher/main.cf new file mode 100644 index 00000000..62a45974 --- /dev/null +++ b/management/command-dispatcher/main.cf @@ -0,0 +1,70 @@ +body file control +{ + namespace => "command_dispatcher"; +} + +bundle agent main +{ + classes: + "enabled" expression => isvariable("commands_to_run"); + "run_$(i)" expression => "$(_condition[$(i)])"; + + vars: + enabled:: + "i" slist => getindices(commands_to_run); + + "_command[$(i)]" + string => "$(commands_to_run[$(i)][command])", + if => isvariable("commands_to_run[$(i)][command]"); + + "_condition[$(i)]" + string => ifelse( + not(strcmp("$(commands_to_run[$(i)][condition])", "")), + "$(commands_to_run[$(i)][condition])", + "any" + ); + + "_ifelapsed[$(i)]" + string => ifelse( + not(strcmp("$(commands_to_run[$(i)][ifelapsed])", "")), + "$(commands_to_run[$(i)][ifelapsed])", + "5" + ); + + reports: + enabled.DEBUG:: + "Command [$(i)]: $(_command[$(i)]), condition: $(_condition[$(i)]), ifelapsed: $(_ifelapsed[$(i)])"; + + !enabled.DEBUG:: + "Command-dispatcher: commands_to_run variable not found"; + + commands: + enabled:: + "$(_command[$(i)])" + if => "run_$(i)", + action => ifelapsed("$(_ifelapsed[$(i)])"), + contain => in_shell; +} + +body contain in_shell +{ + useshell => "true"; + exec_owner => "root"; + exec_timeout => "300"; +} + +body action ifelapsed(x) +{ + ifelapsed => "$(x)"; +} + +body file control +{ + namespace => "default"; +} + +bundle agent __main__ +{ + methods: + "command_dispatcher:main"; +} diff --git a/management/demo/def.json b/management/demo/def.json index 0735d827..ed6de96a 100644 --- a/management/demo/def.json +++ b/management/demo/def.json @@ -1,15 +1,14 @@ { - "classes": { - "mpf_augments_control_enabled": ["any"], - "cfengine_internal_purge_policies": ["any"], - "cfengine_mp_fr_dependencies_auto_install": ["any"] - }, - "vars": { - "acl": ["0.0.0.0/0", "::/0"], - "default_data_select_host_monitoring_include": [".*"], - "default_data_select_policy_hub_monitoring_include": [".*"], - "control_executor_splaytime": "1", - "control_executor_schedule": ["any"], - "control_hub_hub_schedule": ["any"] - } + "classes": { + "mpf_augments_control_enabled": ["any"], + "cfengine_mp_fr_dependencies_auto_install": ["any"] + }, + "vars": { + "acl": ["0.0.0.0/0", "::/0"], + "default_data_select_host_monitoring_include": [".*"], + "default_data_select_policy_hub_monitoring_include": [".*"], + "control_executor_splaytime": "1", + "control_executor_schedule": ["any"], + "control_hub_hub_schedule": ["any"] + } } diff --git a/management/disable-automatic-key-trust/README.md b/management/disable-automatic-key-trust/README.md new file mode 100644 index 00000000..9b12632d --- /dev/null +++ b/management/disable-automatic-key-trust/README.md @@ -0,0 +1,91 @@ +CFEngine provides a way to automatically trust keys from other hosts, which is enabled for all hosts on the same `/16` subnet as the hub by default. +Once your hosts are bootstrapped, or if you are using another way to establish trust (distribute keys) it is recommended to disable the automatic trust mechanism. +This module helps you do that, it is equivalent to editing the augments file (`/var/cfengine/masterfiles/def.json`) to: + +```json +{ + "variables": { + "default:def.trustkeysfrom": [] + } +} +``` + +After disabling automatic trust, take a look at: + +- The [allow-hosts](https://build.cfengine.com/modules/allow-hosts/) module for specifying which hosts should be allowed to connect / fetch policy. +- The [secure bootstrap documentation](https://docs.cfengine.com/docs/3.21/getting-started-installation-secure-bootstrap.html) for more information on how to establish trust (distribute keys). + +## Details + +To connect / bootstrap a new client to a CFEngine hub, three things need to happen: + +1. It must be **allowed to connect** over the network. +2. Its cryptographic **key must be trusted** by the hub. +3. It must be **allowed to access** the policy files it wants to download. + +In CFEngine, these 3 things are controlled by the variables `def.control_server_allowconnects`, `def.trustkeysfrom` and `def.acl` (respectively). + +By default, `def.acl` is set to the `/16` subnet of the hub's IP address. +If not overriden, the two other variables are set to default to the value as `def.acl`. + +This means that, by default, any host which is on the same `/16` subnet as the hub is allowed to connect, automatically bootstrap and access the policy files. +This configuration is intented to make it easy to get started / test, but should be edited to a more secure configuration in a production environment. + +By default, CFEngine only accepts incoming connections from IP addresses on the same network (`/16` subnet). +This module changes the setting to allow all IP addresses. + +**Note:** The variables and defaults mentioned here are for the default CFEngine policy set (MPF). +If you are only using the CFEngine binaries, not the default policy, these variables don't do anything special. + +For more information on this subject, see the [secure bootstrap section of our documentation](https://docs.cfengine.com/docs/3.21/getting-started-installation-secure-bootstrap.html). + +## Examples + +To better illustrate how this works, and what options are available, I've included some examples of what you could put in your `def.json` file below. + +Allow connections from anywhere but don't trust any new keys: + +```json +{ + "variables": { + "default:def.acl": ["0.0.0.0/0", "::/0"], + "default:def.control_server_allowconnects": ["0.0.0.0/0", "::/0"], + "default:def.trustkeysfrom": [] + } +} +``` + +If you have set up just a couple of hosts, and want to only allow those, this is easy: + +```json +{ + "variables": { + "default:def.acl": ["1.2.3.4", "4.3.2.1"] + } +} +``` + +(The IP addresses `1.2.3.4` and `4.3.2.1` are just examples here, replace them with the actual IP addresses of your hosts.) + +Because of the defaults mentioned above, this is equivalent to: + +```json +{ + "variables": { + "default:def.acl": ["1.2.3.4", "4.3.2.1"], + "default:def.control_server_allowconnects": ["1.2.3.4", "4.3.2.1"], + "default:def.trustkeysfrom": ["1.2.3.4", "4.3.2.1"] + } +} +``` + +Finally, if you want only those 2 hosts to communicate, and only with those keys they already have, you can do this: + +```json +{ + "variables": { + "default:def.acl": ["1.2.3.4", "4.3.2.1"], + "default:def.trustkeysfrom": [] + } +} +``` diff --git a/management/disable-automatic-key-trust/def.json b/management/disable-automatic-key-trust/def.json new file mode 100644 index 00000000..3a57315e --- /dev/null +++ b/management/disable-automatic-key-trust/def.json @@ -0,0 +1,3 @@ +{ + "variables": { "default:def.trustkeysfrom": [] } +} diff --git a/management/disable-recommendations/README.md b/management/disable-recommendations/README.md new file mode 100644 index 00000000..6f051caf --- /dev/null +++ b/management/disable-recommendations/README.md @@ -0,0 +1,20 @@ +The Masterfiles Policy Framework (MPF) emits recommendations for various settings given the context and role of a host. + +For example, when federated reporting is enabled on an Enterprise hub, there is a recommendation to install gnu-parallel. + +``` +R: CFEngine recommends installing gnu parallel on federated reporting superhubs. +``` + +These can be useful, but you may want to disable the functionality to quiet your policy runs. This module facilitates disabling all recommendations by defining the `default:cfengine_recommendations_disabled` class. Thus it is an equivalent to editing the augments file (`/var/cfengine/masterfiles/def.json`) to: + +```json +{ + "classes": { + "default:cfengine_recommendations_disabled": { + "class_expressions": [ "any::" ], + "comment": "We disabled all recommendations emitted by the MPF to quiet policy output." + } + } +} +``` diff --git a/management/disable-recommendations/def.json b/management/disable-recommendations/def.json new file mode 100644 index 00000000..48f83646 --- /dev/null +++ b/management/disable-recommendations/def.json @@ -0,0 +1,8 @@ +{ + "classes": { + "default:cfengine_recommendations_disabled": { + "class_expressions": ["any::"], + "comment": "We disabled all recommendations emitted by the MPF to quiet policy output." + } + } +} diff --git a/management/every-minute/def.json b/management/every-minute/def.json index 0b329788..9f4fbbb8 100644 --- a/management/every-minute/def.json +++ b/management/every-minute/def.json @@ -1,7 +1,7 @@ { - "vars": { - "control_executor_splaytime": "1", - "control_executor_schedule": ["any"], - "control_hub_hub_schedule": ["any"] - } + "vars": { + "control_executor_splaytime": "1", + "control_executor_schedule": ["any"], + "control_hub_hub_schedule": ["any"] + } } diff --git a/management/manage-fwupd/README.org b/management/manage-fwupd/README.org new file mode 100644 index 00000000..8436cfe7 --- /dev/null +++ b/management/manage-fwupd/README.org @@ -0,0 +1,110 @@ +Ensure fwupd is installed and its metadata refresh timer is enabled. +Optionally apply firmware updates for devices matching an allow-list. + +Depends on *inventory-fwupd*, which surfaces firmware versions, +pending updates, and HSI security attributes as inventory. This module +reads the updates cache that inventory-fwupd maintains rather than +calling fwupdmgr itself, avoiding duplicate work. + +* Inputs + +All three inputs live in the =manage_fwupd:allowed= bundle and can be +set via ~cfbs input~ (interactive), augments (=def.json=), CMDB, or +host-specific data. The variable names are: + +| Variable | Type | Default | +|--------------------------------------------+--------------------------+---------------| +| =manage_fwupd:allowed.apply_updates= | string (class expression) | =!any= | +| =manage_fwupd:allowed.device_name_reglist= | list (pcre patterns) | ={}= (empty) | +| =manage_fwupd:allowed.reboot_after_update= | string (class expression) | =!any= | + +*Augments example* (=def.json=): + +#+begin_src json +{ + "variables": { + "manage_fwupd:allowed.apply_updates": { + "value": "any" + }, + "manage_fwupd:allowed.device_name_reglist": { + "value": ["UEFI dbx", "System Firmware"] + }, + "manage_fwupd:allowed.reboot_after_update": { + "value": "Night" + } + } +} +#+end_src + +** Apply firmware updates + +A CFEngine class expression controlling when firmware updates are applied. +The default is =!any=, which means updates are never applied until you +explicitly opt in by changing this value. + +Examples: + +| Expression | Meaning | +|-------------------------------------------------+-----------------------------------------------------| +| =!any= | Disabled (default) -- never apply firmware updates | +| =any= | Apply on all hosts, always | +| =linux= | Apply on all Linux hosts | +| =(env_dev\vert{}env_qa).Night.(cohort_A\vert{}cohort_C)= | Dev/QA environments, at night, for cohorts A and C | +| =Hr04.Min00_05= | All hosts, but only during a 4:00--4:05 AM window | +| =laptop_updates_enabled= | Only hosts where you've defined this custom class | + +Use =.= for AND, =|= for OR, =!= for NOT, and parentheses for grouping. +These are CFEngine class expressions, not regular expressions. + +** Allowed devices + +A list of device name patterns (regular expressions) controlling which +devices are eligible for firmware updates. Even when =apply_updates= is +enabled, only devices whose name matches an entry in this list will be +updated. + +Patterns are unanchored (substring match), so =UEFI dbx= matches any +device whose name contains "UEFI dbx". Use =^…$= to anchor if you need +an exact match. Use =.*= to match all devices. + +** Reboot after update + +A CFEngine class expression controlling when the host reboots after a +firmware update is applied. The default is =!any= (disabled). Some firmware +updates (e.g. UEFI capsule) only activate after a reboot. + +When enabled, the module waits for cf-agent to finish before rebooting. +On systemd systems this uses a transient service that polls for cf-agent's +PID to exit, then reboots immediately. On non-systemd systems it falls +back to ~shutdown -r +1~. + +* Requirements + +Linux only. The =fwupd= package must be available in the system's +package repositories. + +* Warnings + +Firmware updates carry inherent risk (bricking, required reboots, AC +power requirements). Use the allow-list to limit updates to +well-understood device classes. Test in a non-production environment +first. + +* Mission Portal + +The module reports =fwupd management status= as an inventory attribute +visible in Mission Portal's inventory reports. + +* Behavior + +1. *Always:* Ensures the =fwupd= package is installed +2. *Always:* Ensures =fwupd-refresh.timer= is enabled (keeps the LVFS firmware catalog current) +3. *When =apply_updates= class resolves true AND a device matches the allow-list:* + Runs ~fwupdmgr update --no-reboot-check ~ for each matching device. + A marker file prevents the same update from re-executing within the + same boot cycle. +4. *When =reboot_after_update= resolves true AND a firmware update was applied:* + Schedules a deferred reboot after cf-agent exits. + +The =--no-reboot-check= flag suppresses the interactive reboot prompt. +It does not skip or defer the update itself. diff --git a/management/manage-fwupd/input.json b/management/manage-fwupd/input.json new file mode 100644 index 00000000..5a4d05cb --- /dev/null +++ b/management/manage-fwupd/input.json @@ -0,0 +1,33 @@ +[ + { + "type": "string", + "variable": "apply_updates", + "namespace": "manage_fwupd", + "bundle": "allowed", + "label": "Apply firmware updates", + "question": "Class expression for when firmware updates should be applied (e.g. 'any', 'linux', '(env_dev|env_qa).Night.(cohort_A|cohort_C)'). Default '!any' means disabled.", + "default": "!any" + }, + { + "type": "list", + "variable": "device_name_reglist", + "namespace": "manage_fwupd", + "bundle": "allowed", + "label": "Allowed devices", + "subtype": { + "type": "string", + "label": "Device name pattern", + "question": "Device name (regex) to allow firmware updates for" + }, + "while": "Do you want to allow firmware updates for more devices?" + }, + { + "type": "string", + "variable": "reboot_after_update", + "namespace": "manage_fwupd", + "bundle": "allowed", + "label": "Reboot after firmware update", + "question": "Class expression for when to reboot after a firmware update is applied (e.g. 'any', 'Night', 'Hr04.Min00_05'). Default '!any' means disabled (no automatic reboot).", + "default": "!any" + } +] diff --git a/management/manage-fwupd/main.cf b/management/manage-fwupd/main.cf new file mode 100644 index 00000000..5304c720 --- /dev/null +++ b/management/manage-fwupd/main.cf @@ -0,0 +1,206 @@ +body file control +{ + namespace => "manage_fwupd"; +@if minimum_version(3.27) + evaluation_order => "top_down"; +@endif +} + +bundle agent allowed +# @brief Default values for firmware update configuration. +# Override apply_updates, device_name_reglist, and reboot_after_update +# via CMDB or augments to control firmware update behavior. +{ + vars: + "apply_updates" + string => "!any", + if => not(isvariable("apply_updates")), + comment => "Default disabled; set to a class expression to enable firmware updates"; + + "device_name_reglist" + slist => {}, + if => not(isvariable("device_name_reglist")), + comment => "Default empty allow-list; override via CMDB or augments"; + + "reboot_after_update" + string => "!any", + if => not(isvariable("reboot_after_update")), + comment => "Default disabled; set to a class expression to reboot after firmware update"; +} + +bundle agent main +# @brief Ensure fwupd is installed, its refresh timer is enabled, and +# optionally apply firmware updates for devices matching an allow-list. +# +# Depends on inventory-fwupd, which provides: +# - inventory_fwupd_main.fwupdmgr (path to fwupdmgr binary) +# - default:have_fwupdmgr (class: fwupdmgr is present) +# - updates cache at $(sys.statedir)/fwupd/inventory_updates.json +# +# Firmware updates are applied when the apply_updates class expression resolves +# true AND a device name matches at least one pcre regular expression defined in +# manage_fwupd:allowed.device_name_reglist. +# +# A marker file ($(sys.statedir)/fwupd/update_applied) prevents the +# same update from re-executing on subsequent agent runs within the +# same boot cycle. The marker's mtime is compared against /proc/1 +# (boot time) via isnewerthan(). Any marker remaining from a prior boot +# is deleted so the update can re-evaluate after reboot. +# +# When reboot_after_update resolves true the marker is removed and +# a deferred reboot is scheduled. On systemd systems a transient +# service polls for cf-agent to exit then reboots immediately; on +# non-systemd systems the legacy shutdown -r +1 fallback is used. +# +# --no-reboot-check suppresses the interactive "reboot now?" prompt +# that fwupdmgr emits after certain updates. It does not skip or +# defer the update itself. +# +# @inventory fwupd management status - INSTALLED, NOT_INSTALLED, or NOT_SUPPORTED +{ + vars: + linux:: + "_update_marker" string => "$(sys.statedir)/fwupd/update_applied"; + + "_systemd_run" + string => ifelse( + isexecutable("/bin/systemd-run"), + "/bin/systemd-run", + isexecutable("/usr/bin/systemd-run"), + "/usr/bin/systemd-run", + "" + ), + comment => "Path to systemd-run when available (empty string otherwise)"; + + linux.default:have_fwupdmgr.manage_fwupd:apply_updates:: + "_updates_cache" string => "$(sys.statedir)/fwupd/inventory_updates.json"; + + "_updates" + data => readjson("$(_updates_cache)"), + if => fileexists("$(_updates_cache)"); + + "_dev_idx" + slist => getindices("_updates[Devices]"), + if => isvariable("_updates"); + + "_dev_name[$(_dev_idx)]" + string => "$(_updates[Devices][$(_dev_idx)][Name])"; + + "_dev_id[$(_dev_idx)]" + string => "$(_updates[Devices][$(_dev_idx)][DeviceId])"; + + classes: + linux:: + "apply_updates" + scope => "namespace", + meta => { "report" }, + expression => "$(manage_fwupd:allowed.apply_updates)", + comment => "Opt-in via class expression (default '!any' = disabled)"; + + "reboot_after_update" + scope => "namespace", + meta => { "report" }, + expression => "$(manage_fwupd:allowed.reboot_after_update)", + comment => "Opt-in reboot after firmware update (default '!any' = disabled)"; + + "_have_systemd_run" + expression => isgreaterthan(string_length("$(_systemd_run)"), 0), + comment => "True when systemd-run is available for deferred reboot"; + + "_update_applied" + expression => and( + fileexists("$(_update_marker)"), + isnewerthan("$(_update_marker)", "/proc/1") + ), + comment => "A firmware update was applied during this boot cycle"; + + linux.default:have_fwupdmgr.manage_fwupd:apply_updates:: + "_device_allowed_$(_dev_idx)" + expression => regcmp( + "$(manage_fwupd:allowed.device_name_reglist)", + "$(_dev_name[$(_dev_idx)])" + ), + comment => "Device $(_dev_name[$(_dev_idx)]) matches allow-list entry"; + + packages: + linux:: + "fwupd" + policy => "present", + comment => "Ensure fwupd is installed for firmware inventory and management"; + + services: + linux.default:have_fwupdmgr:: + "fwupd-refresh.timer" + service_policy => "enabled", + comment => "Ensure the fwupd metadata refresh timer is enabled so the LVFS firmware catalog stays current"; + + # --- Stale marker cleanup ---------------------------------------- + files: + linux:: + "$(_update_marker)" + delete => default:tidy, + if => not("_update_applied"), + comment => "Remove stale marker from a previous boot cycle"; + + # --- Apply firmware updates -------------------------------------- + commands: + linux.default:have_fwupdmgr.manage_fwupd:apply_updates:: + "$(default:inventory_fwupd_main.fwupdmgr)" + arglist => { "update", "--no-reboot-check", "$(_dev_id[$(_dev_idx)])" }, + handle => canonify( + "manage_fwupd_apply_device_$(_dev_idx)_$(_dev_name[$(_dev_idx)])" + ), + if => and("_device_allowed_$(_dev_idx)", not("_update_applied")), + classes => default:results("bundle", "manage_fwupd_update_$(_dev_idx)"), + comment => "Apply firmware update to allowed device $(_dev_name[$(_dev_idx)])"; + + # --- Post-update marker and reboot -------------------------------- + files: + linux.default:have_fwupdmgr.manage_fwupd:apply_updates:: + "$(_update_marker)" + create => "true", + if => "manage_fwupd_update_$(_dev_idx)_repaired", + comment => "Record that a firmware update was applied this boot cycle (prevents re-execution)"; + + manage_fwupd:_update_applied.manage_fwupd:reboot_after_update:: + "$(_update_marker)" + delete => default:tidy, + comment => "Clear marker before issuing reboot"; + + commands: + manage_fwupd:_update_applied.manage_fwupd:reboot_after_update.manage_fwupd:_have_systemd_run:: + "$(_systemd_run)" + arglist => { + "--unit=cfengine-fwupd-reboot", + "--description=Reboot after cf-agent exits for firmware update", + "/bin/bash", + "-c", + "while kill -0 $(this.promiser_pid) 2>/dev/null; do sleep 1; done; /sbin/shutdown -r now 'Rebooting for firmware update applied by CFEngine'", + }, + handle => "manage_fwupd_reboot_after_update", + comment => "Deferred reboot: polls for cf-agent to exit, then reboots immediately"; + + manage_fwupd:_update_applied.manage_fwupd:reboot_after_update.!manage_fwupd:_have_systemd_run:: + "/sbin/shutdown" + arglist => { + "-r", "+1", "Rebooting for firmware update applied by CFEngine" + }, + handle => "manage_fwupd_reboot_after_update_fallback", + comment => "Fallback reboot for non-systemd systems (shutdown -r +1)"; + + # --- Inventory -------------------------------------------------- + vars: + linux.default:have_fwupdmgr:: + "_status" string => "INSTALLED"; + + linux.!default:have_fwupdmgr:: + "_status" string => "NOT_INSTALLED"; + + !linux:: + "_status" string => "NOT_SUPPORTED"; + + any:: + "inventory_status" + string => "$(_status)", + meta => { "inventory", "attribute_name=fwupd management status" }; +} diff --git a/management/package-method-ansible-galaxy-collection/README.md b/management/package-method-ansible-galaxy-collection/README.md new file mode 100644 index 00000000..16737517 --- /dev/null +++ b/management/package-method-ansible-galaxy-collection/README.md @@ -0,0 +1,18 @@ +# package-method-ansible-galaxy-collection + +## Usage +This module enables a `ansible-galaxy-collection` package method used with policy like: + +```cf3 +packages: + "ansible.posix" + package_method => ansible_galaxy_collection, + package_policy => "add"; +``` + +Note that there is not a command option in ansible-galaxy to uninstall collections so that will result in no changes being made and a warning message. Ansible documentation suggests [removing collections with rm](https://docs.ansible.com/projects/ansible/latest/collections_guide/collections_installing.html#removing-a-collection) + +## Installation + +This module does not ensure that needed ansible packages are installed to provide the `ansible-galaxy` command. +Typically this is a package named `ansible` on Debian-based distributions or `ansible-core` on RedHat-based distributions. diff --git a/management/package-method-ansible-galaxy-collection/package-method-ansible-galaxy-collections.cf b/management/package-method-ansible-galaxy-collection/package-method-ansible-galaxy-collections.cf new file mode 100644 index 00000000..0cb0ab2f --- /dev/null +++ b/management/package-method-ansible-galaxy-collection/package-method-ansible-galaxy-collections.cf @@ -0,0 +1,12 @@ +body package_method ansible_galaxy_collection +{ + package_changes => "bulk"; + package_name_convention => "$(name)"; + package_delete_convention => "$(name)"; + package_installed_regex => ".*"; + package_list_name_regex => '^"([^"]*)",.*'; + package_list_version_regex => '.*,"([^"]*)".*'; + package_list_command => "ansible-galaxy collection list"; + package_delete_command => 'echo "Deleting an Ansible Galaxy Collection is not supported"'; + package_add_command => "ansible-galaxy collection install "; +} diff --git a/management/package-method-winget/README.md b/management/package-method-winget/README.md new file mode 100644 index 00000000..c9fd99ec --- /dev/null +++ b/management/package-method-winget/README.md @@ -0,0 +1,52 @@ +# package-method-winget + +## Usage +This module enables a `winget` package method used with policy like: + +```cf3 +packages: + windows:: + "Microsoft.WindowsTerminal" + package_method => winget, + package_policy => "add"; +``` + +## Opt-in for accepting source and package agreements (Required) + +In order for winget to operate properly you must opt-in to accepting source and packaging agreements. +Without this acceptance the package method will fail with a message like: + +```console + info: Installing Microsoft.WindowsTerminal... + info: Q:powershell.exe -Comm ...:You must set some vars for package-method-winget to work + error: Finished command related to promiser 'Microsoft.WindowsTerminal' -- an error occurred, returned 1 + error: Bulk package schedule execution failed somewhere - unknown outcome for 'Microsoft.WindowsTerminal' +``` + +Acceptance can be given either via cfbs inputs, augments, group or host specific data. + +Set the value of `yes` in the following variables: +- `data:package_method_winget.accept_source_agreements` +- `data:package_method_winget.accept_package_agreements` + +## Installation +This module uses both the `winget` command as well as the `Microsoft.WinGet.Client` PowerShell module as that makes it easier to gather the list of currently installed packages. + +`winget` should be installed on most newer desktop systems by default. +Server images often do not have `winget` installed. + +In order for `winget` and `Microsoft.WinGet.Client` to be installed, ps1 scripts must be run which requires `PowerShell Execution policy` `Unrestricted` for `LocalMachine`. +The policy by default will not make this change. +You must opt-in by setting the variable `winget_installed.allow_powershell_execution_policy_change` to the value of `yes`. +This can be set in Host/Group data or via Augments in the `data` namespace. + +```json +{ + "variables": { + "data:winget_installed.allow_powershell_execution_policy_change": { + "value": "yes" + } + } +} +``` + diff --git a/management/package-method-winget/install-winget-cli.ps1 b/management/package-method-winget/install-winget-cli.ps1 new file mode 100644 index 00000000..3e002c69 --- /dev/null +++ b/management/package-method-winget/install-winget-cli.ps1 @@ -0,0 +1,6 @@ +# instructions from https://github.com/microsoft/winget-cli +# WinGet.Client needs NuGet +Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force +Set-PSRepository -Name PSGallery -InstallationPolicy Trusted +Install-Module -Name Microsoft.WinGet.Client +Import-Module -Name Microsoft.WinGet.Client diff --git a/management/package-method-winget/install-winget.ps1 b/management/package-method-winget/install-winget.ps1 new file mode 100644 index 00000000..f7e7a59e --- /dev/null +++ b/management/package-method-winget/install-winget.ps1 @@ -0,0 +1,11 @@ +# from https://learn.microsoft.com/en-us/windows/package-manager/winget/#install-winget +# TODO, find a way to install the "latest" instead of these hard-coded versions +# as-is if newer versions are already installed this script should still succeed and serve it's purpose +$progressPreference = 'silentlyContinue' +Write-Information "Downloading WinGet and its dependencies..." +Invoke-WebRequest -Uri https://aka.ms/getwinget -OutFile Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle +Invoke-WebRequest -Uri https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx -OutFile Microsoft.VCLibs.x64.14.00.Desktop.appx +Invoke-WebRequest -Uri https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.x64.appx -OutFile Microsoft.UI.Xaml.2.8.x64.appx +Add-AppxPackage Microsoft.VCLibs.x64.14.00.Desktop.appx +Add-AppxPackage Microsoft.UI.Xaml.2.8.x64.appx +Add-AppxPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle diff --git a/management/package-method-winget/package-method-winget.cf b/management/package-method-winget/package-method-winget.cf new file mode 100644 index 00000000..6fccf5b3 --- /dev/null +++ b/management/package-method-winget/package-method-winget.cf @@ -0,0 +1,64 @@ +# NOTE: CASE MATTERS in package names, e.g. Docker.DockerDesktop will match but docker.dockerdesktop won't and so will re-install each agent run! +# TODO: fix case sensitive package names? ^^^ +body package_method winget +{ + package_changes => "bulk"; + package_name_convention => "$(name)"; + package_delete_convention => "$(name)"; + package_installed_regex => ".*"; + + # Note that package_list_name_regex does not allow for commas inside of package names as parsing that from ConvertTo-Csv below would be too complex + # an example of output of the package_list_command below is + # + # "Microsoft.WindowsTerminal","1.21.2361.0" + # + # so the matches below simply grab the thing before or after the comma separating Id and InstalledVersion fields. + package_list_name_regex => '^"([^"]*)",.*'; + package_list_version_regex => '.*,"([^"]*)".*'; + + # Here we use the Get-WinGetPackage Cmdlet because it is easier to produce easily parsed information that way + package_list_command => "$(sys.winsysdir)\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"Get-WinGetPackage | Select Id,InstalledVersion | ConvertTo-Csv "; + package_delete_command => "$(sys.winsysdir)\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"Uninstall-WinGetPackage "; + + # Here we use winget instead of PowerShell Cmdlets because we can provide the --accept-source-agreements and --accept-package-agreements this way which gets around dialog prompts + package_method_winget:accept_source_agreements.package_method_winget:accept_package_agreements:: + package_add_command => "$(sys.winsysdir)\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"winget install --accept-source-agreements --accept-package-agreements "; + + !package_method_winget:accept_source_agreements|!package_method_winget:accept_package_agreements:: + # the package name is appended to the end of this command, so we try here to make a command which conveys information only + package_add_command => "$(sys.winsysdir)\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"Write-Host You must set some vars for package-method-winget to work;exit 1; rem Trying to add package:"; +} + +# switch to module specific namespace to avoid name collisions +body file control +{ + namespace => "package_method_winget"; +} + +# package_method_winget bundle's purpose is to look at inputs/data for acceptance of source and package agreements +# these MUST be agreed to in order for this package method to work properly. +bundle agent package_method_winget +{ + classes: + "accept_source_agreements" + expression => regcmp( + "^[yY][eE]?[sS]?", + "${data:package_method_winget.accept_source_agreements}" + ), + scope => "namespace"; + + "accept_package_agreements" + expression => regcmp( + "^[yY][eE]?[sS]?", + "${data:package_method_winget.accept_package_agreements}" + ), + scope => "namespace"; + + reports: + windows:: + "Please specify if you wish to --accept-source-agreements when using winget package method promises with the cfbs inputs or the variable data:package_method_winget.accept_source_agreements having the value yes or no" + if => "!accept_source_agreements"; + + "Please specify if you wish to --accept-package-agreements when using winget package method promises with cfbs inputs or the variable data:package_method_winget.accept_package_agreements having the value yes or no" + if => "!accept_package_agreements"; +} diff --git a/management/package-method-winget/winget-installed.cf b/management/package-method-winget/winget-installed.cf new file mode 100644 index 00000000..2d558ad2 --- /dev/null +++ b/management/package-method-winget/winget-installed.cf @@ -0,0 +1,51 @@ +body file control +{ + namespace => "winget_installed"; +} + +bundle agent winget_installed +{ + classes: + windows:: + "winget_not_installed" + expression => not(returnszero("winget -v | out-null", "powershell")); + + "winget_cli_not_installed" + expression => not( + returnszero("Get-WinGetPackage | out-null", "powershell") + ); + + "allow_powershell_execution_policy_change" + expression => regcmp( + "^[yY][eE]?[sS]?", + "${data:winget_installed.allow_powershell_execution_policy_change}" + ), + scope => "namespace"; + + commands: + windows.!winget_installed.execution_policy_ok:: + "powershell.exe -File '${this.promise_dirname}/install-winget.ps1'" + contain => default:powershell; + + windows.!winget_cli_installed.execution_policy_ok:: + "powershell.exe -File '${this.promise_dirname}/install-winget-cli.ps1'" + contain => default:powershell; + + methods: + windows.(winget_not_installed|winget_cli_not_installed).allow_powershell_execution_policy_change:: + "powershell_execution_policy_set" + usebundle => default:powershell_execution_policy_set( + "LocalMachine", "Unrestricted" + ), + classes => default:if_ok("execution_policy_ok"); + + reports: + windows.winget_not_installed:: + "In order for package-module-winget to function properly, winget must be installed."; + + windows.winget_cli_not_installed:: + "In order for package-module-winget to function properly, winget-cli must be installed and imported."; + + windows.(winget_not_installed|winget_cli_not_installed).!allow_powershell_execution_policy_change:: + "package-module-winget needs winget and/or winget-cli installed. Opt-in for this to be automated by this policy by setting the variable data:winget_installed.allow_powershell_execution_policy_change to 'yes'. This can be accomplished via cfbs inputs, group/host data or augments."; +} diff --git a/management/policy-dispatcher/README.md b/management/policy-dispatcher/README.md new file mode 100644 index 00000000..b3b7285d --- /dev/null +++ b/management/policy-dispatcher/README.md @@ -0,0 +1,33 @@ +This module enables the running of CFEngine policy snippets based on input from Build in Mission Portal or `cfbs input`. + +Each policy snippet is saved to a file in `/var/cfengine/state/policy-dispatcher/.cf` and then run with `cf-agent --no-lock --file `, based on the `condition`-variable. + +**Note:** The policy snippets are run with root-privilege (uid=0). +Each snippet must be a complete, standalone policy file, containing a `main` bundle (this is what `cf-agent` runs by default). + +**Usage:** + +- `policy` - The policy snippet to save and run. Must contain a `main` bundle. +- `condition` - Condition for running. Use a class expression (e.g., `linux|bsd`). Defaults to `"any"` +- `ifelapsed` - Number of minutes between assessments. Defaults to 5 minutes. + +E.g. + +```json +{ + "policy": "bundle agent main { reports: \"Hello World\"; } ", + "condition": "linux", + "ifelapsed": "5" +} +``` + +Would report "Hello World" on every linux device with 5 minute intervals. + +## Contribute + +Feel free to open pull requests to expand this documentation, add features or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://tracker.mender.io/issues/). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/management/policy-dispatcher/def.json b/management/policy-dispatcher/def.json new file mode 100644 index 00000000..81a59cf2 --- /dev/null +++ b/management/policy-dispatcher/def.json @@ -0,0 +1,19 @@ +{ + "variables": { + "policy_dispatcher:main.policy_files_to_run": { + "value": [ + { + "policy": "bundle agent main\n{\n reports:\n \"Hello World\";\n}\n", + "condition": "any", + "ifelapsed": "5" + }, + { + "policy": "bundle agent main\n{\n files:\n \"/tmp/policy-dispatcher-example.txt\"\n create => \"true\",\n content => \"This file is managed by the policy-dispatcher module\n\";\n}\n", + "condition": "linux", + "ifelapsed": "60" + } + ], + "comment": "Example data generated by 'cfbs input' / Mission Portal build page" + } + } +} diff --git a/management/policy-dispatcher/main.cf b/management/policy-dispatcher/main.cf new file mode 100644 index 00000000..c606ca44 --- /dev/null +++ b/management/policy-dispatcher/main.cf @@ -0,0 +1,96 @@ +body file control +{ + namespace => "policy_dispatcher"; +} + +bundle agent main +{ + classes: + "enabled" expression => isvariable("policy_files_to_run"); + + vars: + enabled:: + "i" slist => getindices(policy_files_to_run); + + "_policy_file[$(i)]" + string => "$(policy_files_to_run[$(i)][policy])", + if => isvariable("policy_files_to_run[$(i)][policy]"); + + "_condition[$(i)]" + string => ifelse( + not(strcmp("$(policy_files_to_run[$(i)][condition])", "")), + "$(policy_files_to_run[$(i)][condition])", + "any" + ); + + "_ifelapsed[$(i)]" + string => ifelse( + not(strcmp("$(policy_files_to_run[$(i)][ifelapsed])", "")), + "$(policy_files_to_run[$(i)][ifelapsed])", + "5" + ); + + "_file[$(i)]" string => "$(sys.statedir)/policy-dispatcher/$(i).cf"; + + "_expected_files" + slist => maplist("$(sys.statedir)/policy-dispatcher/$(this).cf", @(i)); + + "_actual_files" + slist => findfiles("$(sys.statedir)/policy-dispatcher/*.cf"); + + "_unexpected_files" slist => difference(_actual_files, _expected_files); + + files: + enabled:: + "$(_file[$(i)])" + create => "true", + content => "$(_policy_file[$(i)])", + perms => mo("600", "root"), + if => "$(_condition[$(i)])"; + + "$(_unexpected_files)" delete => tidy; + + reports: + enabled.DEBUG:: + "Policy file [$(i)]: $(_file[$(i)]), condition: $(_condition[$(i)]), ifelapsed: $(_ifelapsed[$(i)])"; + + !enabled.DEBUG:: + "policy-dispatcher: policy_files_to_run variable not found"; + + commands: + enabled:: + "$(sys.cf_agent) --no-lock --file $(_file[$(i)])" + if => "$(_condition[$(i)])", + action => ifelapsed("$(_ifelapsed[$(i)])"); +} + +body delete tidy +# @brief Delete the file and remove empty directories +# and links to directories +{ + dirlinks => "delete"; + rmdirs => "true"; +} + +body perms mo(mode, owner) +{ + mode => "$(mode)"; + owners => { "$(owner)" }; + rxdirs => "false"; +} + +body action ifelapsed(x) +{ + ifelapsed => "$(x)"; +} + +body file control +{ + namespace => "default"; +} + +bundle agent __main__ +{ + methods: + "policy_dispatcher:main"; +} diff --git a/management/powershell-execution-policy/README.md b/management/powershell-execution-policy/README.md new file mode 100644 index 00000000..3fdd9e11 --- /dev/null +++ b/management/powershell-execution-policy/README.md @@ -0,0 +1,11 @@ +# powershell-execution-policy + +This module inventories and allows to set the state of the various `scopes` for PowerShell Execution Policy. + +See the [Set-ExecutionPolicy](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/set-executionpolicy?view=powershell-7.4) documentation for details about scope and state values. + +## Example + +```cf3 + "set_localmachine_unrestricted" usebundle => default:powershell_execution_policy_set("LocalMachine", "Unrestricted"); +``` diff --git a/management/powershell-execution-policy/powershell-execution-policy.cf b/management/powershell-execution-policy/powershell-execution-policy.cf new file mode 100644 index 00000000..912d7c3d --- /dev/null +++ b/management/powershell-execution-policy/powershell-execution-policy.cf @@ -0,0 +1,41 @@ +# Technically this is PowerShell Execution Policy, not Windows specific (TODO: s/windows/powershell/) +# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.4 +bundle agent powershell_execution_policy_inventory +{ + vars: + windows:: + "execution_policy_csv_file" + string => "${sys.statedir}${const.dirsep}powershell_execution_policy_list_cache.csv"; + + "execution_policy_list_cache_command" + string => "Get-ExecutionPolicy -list | ConvertTo-Csv -notypeinformation | select-object -skip 1 | Set-Content -Path '${execution_policy_csv_file}'"; + + "csv" data => readcsv("${execution_policy_csv_file}"); + "i" slist => getindices("csv"); + + "execution_policy_${csv[${i}][0]}" + string => "${csv[${i}][0]}:${csv[${i}][1]}", + meta => { "inventory", "attribute_name=PowerShell Execution Policy" }; + + commands: + windows:: + "${execution_policy_list_cache_command}" contain => powershell; +} + +# see link below for valid values for scope and policy +# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/set-executionpolicy?view=powershell-7.4 +# This bundle runs a powershell command: Set-ExecutionPolicy -ExecutionPolicy -Scope +bundle agent powershell_execution_policy_set(scope, desired_policy) +{ + classes: + "policy_not_ok" + expression => returnszero( + "if((Get-ExecutionPolicy ${scope}) -ne '${desired_policy}'){exit 0}else{exit 1}", + "powershell" + ); + + commands: + windows.policy_not_ok:: + "Set-ExecutionPolicy -ExecutionPolicy ${desired_policy} -Scope ${scope}" + contain => powershell; +} diff --git a/management/windows-capability/windows-capability.cf b/management/windows-capability/windows-capability.cf new file mode 100644 index 00000000..d1a1bc70 --- /dev/null +++ b/management/windows-capability/windows-capability.cf @@ -0,0 +1,88 @@ +bundle agent windows_capability_installed(capability_name) +{ + methods: + "Installed" + usebundle => windows_capability:_windows_capability_state( + "${capability_name}", "Installed" + ); +} + +bundle agent windows_optionial_capability_notpresent(capability_name) +{ + methods: + "NotPresent" + usebundle => windows_capability:_windows_capability_state( + "${capability_name}", "NotPresent" + ); +} + +bundle agent windows_capability +{ + methods: + windows.!data:disable_windows_capability_inventory:: + "windows_capability:_inventory"; +} + +body file control +{ + namespace => "windows_capability"; +} + +# https://learn.microsoft.com/en-us/powershell/module/dism/get-windowscapability?view=windowsserver2022-ps +# https://learn.microsoft.com/en-us/powershell/module/dism/remove-windowscapability?view=windowsserver2022-ps +# https://learn.microsoft.com/en-us/powershell/module/dism/add-windowscapability?view=windowsserver2022-ps +bundle agent _inventory +{ + vars: + windows:: + "cache_file" + string => "${sys.statedir}${const.dirsep}${this.namespace}_cache.csv"; + + "command" + string => "Get-WindowsCapability -Online | ConvertTo-Csv -notypeinformation | select-object -skip 1 | Set-Content -Path '${cache_file}'"; + + "csv" data => readcsv("${cache_file}"); + "i" slist => getindices("csv"); + + "${this.namespace}[${i}]" + string => "${csv[${i}][0]}:${csv[${i}][1]}", + meta => { "inventory", "attribute_name=Windows Capability" }; + + files: + "${cache_file}" + file_select => default:days_old(1), + delete => default:tidy; + + commands: + windows:: + "${command}" + if => not(fileexists("${cache_file}")), + contain => default:powershell; +} + +bundle agent _windows_capability_state(capability_name, desired_state) +{ + vars: + "operation" + string => ifelse( + strcmp("${desired_state}", "Installed"), "Add", "Remove" + ); + + classes: + windows:: + "state_not_ok" + expression => returnszero( + "if((Get-WindowsCapability -Online -Name ${capability_name} | select-object -expandproperty state) -ne '${desired_state}'){exit 0}else{exit 1}", + "powershell" + ); + + commands: + windows.state_not_ok:: + "${operation}-WindowsCapability -Online -Name ${capability_name}" + classes => default:results("bundle", "state"), + contain => default:powershell; + + files: + windows.state_repaired:: + "${_inventory.cache_file}" delete => default:tidy; +} diff --git a/management/windows-optional-feature/windows-optional-feature.cf b/management/windows-optional-feature/windows-optional-feature.cf new file mode 100644 index 00000000..09087885 --- /dev/null +++ b/management/windows-optional-feature/windows-optional-feature.cf @@ -0,0 +1,92 @@ +# https://learn.microsoft.com/en-us/powershell/module/dism/get-windowsoptionalfeature?view=windowsserver2022-ps +# https://learn.microsoft.com/en-us/powershell/module/dism/disable-windowsoptionalfeature?view=windowsserver2022-ps +# https://learn.microsoft.com/en-us/powershell/module/dism/enable-windowsoptionalfeature?view=windowsserver2022-ps +# https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v#enable-hyper-v-using-powershell +bundle agent windows_optional_feature_enabled(feature_name) +{ + methods: + "Enabled" + usebundle => windows_optional_feature:_promise_state( + "${feature_name}", "Enabled" + ); +} + +bundle agent windows_optional_feature_disabled(feature_name) +{ + methods: + "Disabled" + usebundle => windows_optional_feature:_promise_state( + "${feature_name}", "Disabled" + ); +} + +bundle agent windows_optional_feature +{ + methods: + windows.!data:disable_windows_optional_feature_inventory:: + "windows_optional_feature:_inventory"; +} + +body file control +{ + namespace => "windows_optional_feature"; +} + +bundle agent _inventory +{ + vars: + windows:: + "cache_file" + string => "${sys.statedir}${const.dirsep}${this.namespace}_cache.csv"; + + "command" + string => "Get-WindowsOptionalFeature -Online | ConvertTo-Csv -notypeinformation | select-object -skip 1 | Set-Content -Path '${cache_file}'"; + + "csv" data => readcsv("${cache_file}"); + "i" slist => getindices("csv"); + + "${this.namespace}[${i}]" + string => "${csv[${i}][0]}:${csv[${i}][1]}", + meta => { "inventory", "attribute_name=Windows Optional Features" }; + + files: + "${cache_file}" + file_select => default:days_old(1), + delete => default:tidy; + + commands: + windows:: + "${command}" + if => not(fileexists("${cache_file}")), + contain => default:powershell; +} + +bundle agent _promise_state(feature_name, desired_state) +{ + vars: + "operation" + string => ifelse( + strcmp("${desired_state}", "Enabled"), "Enable", "Disable" + ); + + classes: + windows:: + "state_not_ok" + expression => returnszero( + "if((Get-WindowsOptionalFeature -Online -FeatureName ${feature_name} | select-object -expandproperty state) -ne '${state_name}'){exit 0}else{exit 1}", + "powershell" + ); + + commands: + windows.state_not_ok:: + # -All enables parent features if need be + # -NoRestart so that we don't wait forever for a Yes reply from a user + # then if we ran this command due to needing to, restart below. + "${operation}-WindowsOptionalFeature -Online -FeatureName ${feature_name} -NoRestart -All" + contain => default:powershell, + classes => default:results("bundle", "state"); + + files: + windows.state_repaired:: + "${_inventory.cache_file}" delete => default:tidy; +} diff --git a/promise-types/ansible/README.md b/promise-types/ansible/README.md index 0615eb56..c3a375ea 100644 --- a/promise-types/ansible/README.md +++ b/promise-types/ansible/README.md @@ -1,35 +1,6 @@ -# ansible promise module +The `ansible` promise type allows you to run Ansible playbooks from within CFEngine policy. -## Synopsis - -* *Name*: `ansible` -* *Version*: `0.1.1` -* *Description*: Run Ansible playbooks - -## Requirements - -* Ansible >= 2.8.0 - -## Attributes - -| Name | Type | Description| Mandatory | Default | -| --- | --- | --- | --- | --- | -| `playbook` | `string` | Absolute path of the Ansible playbook | No | Promiser | -| `inventory` | `string` | Absolute path of the inventory file | No | - | -| `limit` | `slist` | List of host names to target | No | `{"localhost"}` | -| `tags` | `slist` | List of tags to play | No | `{}` | -| `become` | `boolean` | Set the `become` option | No | `False` | -| `become_method` | `string` | Set the `become_method` option | No | `"sudo"` | -| `become_user` | `string` | Set the `become_user` option | No | `root` | -| `connection` | `string` | Set the `connection` option; possible values: `local`, `ssh` | No | `local` | -| `forks` | `int` | Set the `forks` option | No | `1` | -| `private_key_file` | `string` | Absolute path of the SSH private key to use | No | - | -| `remote_user` | `string` | Set the `remote_user` option | No | `root` | - -## Examples - -Play the `/northern.tech/playbook.yaml` playbook locally, using `/northern.tech/inventory.yaml`, and limiting -the execution to the `helloworld` tag: +For example, you can play the `/northern.tech/playbook.yaml` playbook locally, using `/northern.tech/inventory.yaml`, and limiting the execution to the `helloworld` tag: ```cfengine3 bundle agent main @@ -42,6 +13,28 @@ bundle agent main } ``` +## Requirements + +* Ansible >= 2.8.0 + +## Attributes + +| Name | Type | Description | Mandatory | Default | +| ------------------ | --------- | ------------------------------------------------------------ | --------- | --------------- | +| `playbook` | `string` | Absolute path of the Ansible playbook | No | Promiser | +| `inventory` | `string` | Absolute path of the inventory file | No | - | +| `limit` | `slist` | List of hosts and groups to target | No | `{}` (no limit) | +| `tags` | `slist` | List of tags to play | No | `{}` | +| `become` | `boolean` | Set the `become` option | No | `False` | +| `become_method` | `string` | Set the `become_method` option | No | `"sudo"` | +| `become_user` | `string` | Set the `become_user` option | No | `root` | +| `connection` | `string` | Set the `connection` option; possible values: `local`, `ssh` | No | `local` | +| `forks` | `int` | Set the `forks` option | No | `1` | +| `private_key_file` | `string` | Absolute path of the SSH private key to use | No | - | +| `remote_user` | `string` | Set the `remote_user` option | No | `root` | + +## Examples + This promise can run ansible over ssh targeting multiple hosts, for example: ```cfengine3 @@ -64,9 +57,8 @@ bundle agent main ## Authors -This software was created by the team at [Northern.tech AS](https://northern.tech), with many contributions from the community. Thanks everyone! - -[CFEngine](https://cfengine.com) is sponsored by [Northern.tech AS](https://northern.tech) +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! ## Contribute diff --git a/promise-types/ansible/ansible_promise.py b/promise-types/ansible/ansible_promise.py index 2fb55df0..5fcd619c 100644 --- a/promise-types/ansible/ansible_promise.py +++ b/promise-types/ansible/ansible_promise.py @@ -1,11 +1,11 @@ import os +import sys from typing import Dict, Tuple, List - -from cfengine import PromiseModule, ValidationError, Result +from cfengine_module_library import PromiseModule, ValidationError, Result try: - from ansible import context + import ansible.context as context from ansible.cli import CLI from ansible.executor.playbook_executor import PlaybookExecutor from ansible.inventory.manager import InventoryManager @@ -13,67 +13,77 @@ from ansible.parsing.dataloader import DataLoader from ansible.plugins.callback import CallbackBase from ansible.vars.manager import VariableManager + from ansible.plugins.loader import init_plugin_loader +except ModuleNotFoundError: - ANSIBLE_AVAILABLE = True -except ImportError: - ANSIBLE_AVAILABLE = False + class UnavailableAnsiblePromiseTypeModule(PromiseModule): + def __init__(self, **kwargs): + super(UnavailableAnsiblePromiseTypeModule, self).__init__( + "ansible_promise_module", "0.0.0", **kwargs + ) -if ANSIBLE_AVAILABLE: + def validate_promise(self, promiser: str, attributes: Dict, metadata: Dict): + raise ValidationError("Ansible Python module not available") - class CallbackModule(CallbackBase): - CALLBACK_VERSION = 1.0 - CALLBACK_TYPE = "stdout" - CALLBACK_NAME = "cfengine" + if __name__ == "__main__": + UnavailableAnsiblePromiseTypeModule().start() - def __init__(self, *args, promise=None, **kw): - self.promise = promise - self.hosts = set() - self.changed = False - super(CallbackModule, self).__init__(*args, **kw) + # Nothing below this point can be defined without ansible. + sys.exit(0) - def v2_runner_on_start(self, host, task): - self.hosts.add(str(host)) - self.promise.log_verbose( - "Task '" + task.name + "' started on '" + str(host) + "'" - ) - def v2_runner_on_ok(self, result): - is_changed = result.is_changed() - if is_changed: - self.changed = True - self.promise.log_info( - "Task '" + result.task_name + "' successfully changed" - ) - else: - self.promise.log_verbose( - "Task '" + result.task_name + "' didn't change" - ) +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 1.0 + CALLBACK_TYPE = "stdout" + CALLBACK_NAME = "cfengine" - def v2_runner_on_failed(self, result, **_): - self.promise.log_error("Task '" + result.task_name + "' failed") + def __init__(self, *args, promise, **kw): + self.promise = promise + self.hosts = set() + self.changed = False + super(CallbackModule, self).__init__(*args, **kw) - def v2_runner_on_skipped(self, result): - self.promise.log_error("Task '" + result.task_name + "' was skipped") + def v2_runner_on_start(self, host, task): + self.hosts.add(str(host)) + self.promise.log_verbose( + "Task '" + task.name + "' started on '" + str(host) + "'" + ) - def v2_playbook_on_stats(self, stats): - for host in self.hosts: - summary_dict = stats.summarize(host) - summary = " ".join( - "%s=%s" % (k, v) for k, v in summary_dict.items() if v > 0 + def v2_runner_on_ok(self, result): + is_changed = result.is_changed() + if is_changed: + self.changed = True + self.promise.log_info( + "Task '" + result.task_name + "' successfully changed" + ) + else: + self.promise.log_verbose("Task '" + result.task_name + "' didn't change") + + def v2_runner_on_failed(self, result, ignore_errors=False): + self.promise.log_error("Task '" + result.task_name + "' failed") + + def v2_runner_on_skipped(self, result): + self.promise.log_verbose("Task '" + result.task_name + "' was skipped") + + def v2_playbook_on_stats(self, stats): + for host in self.hosts: + summary_dict = stats.summarize(host) + summary = " ".join( + "%s=%s" % (k, v) for k, v in summary_dict.items() if v > 0 + ) + if summary_dict.get("unreachable"): + self.promise.log_error("Host '" + host + "' is unreachable") + elif summary: + self.promise.log_verbose( + "Summary of the tasks for '" + host + "' is: " + summary ) - if summary_dict.get("unreachable"): - self.promise.log_error("Host '" + host + "' is unreachable") - else: - summary and self.promise.log_verbose( - "Summary of the tasks for '" + host + "' is: " + summary - ) class AnsiblePromiseTypeModule(PromiseModule): def __init__(self, **kwargs): super(AnsiblePromiseTypeModule, self).__init__( - "ansible_promise_module", "0.2.1", **kwargs + "ansible_promise_module", "0.0.0", **kwargs ) def must_be_absolute(v): @@ -84,7 +94,7 @@ def must_be_absolute(v): "playbook", str, default_to_promiser=True, validator=must_be_absolute ) self.add_attribute("inventory", str, validator=must_be_absolute) - self.add_attribute("limit", list, default=["localhost"]) + self.add_attribute("limit", list, default=[]) self.add_attribute("tags", list, default=[]) self.add_attribute("become", bool, default=False) self.add_attribute("become_method", str, default="sudo") @@ -98,14 +108,13 @@ def prepare_promiser_and_attributes(self, promiser, attributes): safe_promiser = promiser.replace(",", "_") return (safe_promiser, attributes) - def validate_promise(self, promiser: str, attributes: Dict, meta: Dict): - if not ANSIBLE_AVAILABLE: - raise ValidationError("Ansible Python module not available") + def validate_promise(self, promiser: str, attributes: Dict, metadata: Dict): + return def evaluate_promise( - self, safe_promiser: str, attributes: Dict, meta: Dict + self, promiser: str, attributes: Dict, metadata: Dict ) -> Tuple[str, List[str]]: - model = self.create_attribute_object(safe_promiser, attributes) + model = self.create_attribute_object(promiser, attributes) classes = [] result = Result.KEPT @@ -138,6 +147,10 @@ def evaluate_promise( loader=loader, sources=(model.inventory,) if model.inventory else (), ) + # An empty subset means no host matches, so leave it alone to target + # everything, the way ansible-playbook does without --limit. + if model.limit: + inventory.subset(model.limit) variable_manager = VariableManager( loader=loader, @@ -145,18 +158,22 @@ def evaluate_promise( version_info=CLI.version_info(gitinfo=False), ) pbex = PlaybookExecutor( - playbooks=[attributes["playbook"]], + playbooks=[model.playbook], inventory=inventory, variable_manager=variable_manager, loader=loader, passwords={}, ) callback = CallbackModule(promise=self) - pbex._tqm._stdout_callback = callback + if hasattr(callback, "_init_callback_methods"): + # Required on ansible-core >= 2.19 after https://github.com/ansible/ansible/pull/85344 + callback._init_callback_methods() + pbex._tqm._callback_plugins = [callback] + pbex._tqm._callbacks_loaded = True exit_code = pbex.run() if exit_code != 0: - classes.append("{safe_promiser}_failed".format(safe_promiser=safe_promiser)) + classes.append("{safe_promiser}_failed".format(safe_promiser=promiser)) result = Result.NOT_KEPT elif callback.changed: result = Result.REPAIRED @@ -165,4 +182,5 @@ def evaluate_promise( if __name__ == "__main__": + init_plugin_loader() AnsiblePromiseTypeModule().start() diff --git a/promise-types/ansible/example.cf b/promise-types/ansible/example.cf index 6d55e707..29f54287 100644 --- a/promise-types/ansible/example.cf +++ b/promise-types/ansible/example.cf @@ -9,7 +9,7 @@ bundle agent main { ansible: "my_playbook" - playbook => "/tmp/playbook.yaml", + playbook => "/tmp/playbook.yaml", inventory => "/tmp/inventory.yaml", - tags => {"helloworld"}; + tags => { "helloworld" }; } diff --git a/promise-types/appstreams/README.md b/promise-types/appstreams/README.md new file mode 100644 index 00000000..34d288db --- /dev/null +++ b/promise-types/appstreams/README.md @@ -0,0 +1,144 @@ +A CFEngine custom promise type for managing AppStream modules on compatible systems. + +## Overview + +The `appstreams` promise type allows you to manage AppStream modules, which are a key feature of RHEL 8+ and compatible systems. AppStreams provide multiple versions of software components that can be enabled or disabled as needed. + +## Features + +- Enable, disable, install, and remove AppStream modules +- Support for specifying streams and profiles +- Automatic stream switching (upgrades and downgrades) +- Generic DNF configuration options support +- Audit trail support via handle and comment attributes + +## Installation + +To install this promise type, copy the `appstreams.py` file to your CFEngine masterfiles directory and configure the promise agent: + +``` +promise agent appstreams +{ + interpreter => "/usr/bin/python3"; + path => "$(sys.workdir)/modules/promises/appstreams.py"; +} +``` + +## Usage + +### Ensure a module is enabled + +``` +bundle agent main +{ + appstreams: + "nodejs" + state => "enabled", + stream => "12"; +} +``` + +### Ensure a module is disabled + +``` +bundle agent main +{ + appstreams: + "nodejs" + state => "disabled"; +} +``` + +### Ensure a module is installed with a specific profile + +``` +bundle agent main +{ + appstreams: + "python36" + state => "installed", + stream => "3.6", + profile => "minimal"; +} +``` + +### Ensure a module is removed + +``` +bundle agent main +{ + appstreams: + "postgresql" + state => "removed"; +} +``` + +### Reset a module to default + +``` +bundle agent main +{ + appstreams: + "nodejs" + state => "default"; +} +``` + +### Stream switching (upgrade or downgrade) + +When a module is already installed with a different stream, the promise type automatically switches to the requested stream: + +``` +bundle agent main +{ + appstreams: + "php" + handle => "main_php_stream_82", + comment => "Upgrade PHP from 8.1 to 8.2 for new features", + state => "installed", + stream => "8.2", + profile => "minimal"; +} +``` + +This will automatically switch from any currently installed stream (e.g., 8.1) to stream 8.2. + +### Using DNF options + +You can pass generic DNF configuration options to control package installation behavior: + +``` +bundle agent main +{ + appstreams: + "php" + state => "installed", + stream => "8.2", + profile => "minimal", + options => { + "install_weak_deps=false", + "best=true" + }; +} +``` + +This installs PHP 8.2 minimal profile without weak dependencies (like httpd). + +## Attributes + +The promise type supports the following attributes: + +- `state` (optional) - Desired state of the module: `enabled`, `disabled`, `installed`, `removed`, `default`, or `reset` (default: `enabled`) +- `stream` (optional) - Specific stream of the module to use. Set to `default` to use the module's default stream. +- `profile` (optional) - Specific profile of the module to install. Set to `default` to use the module stream's default profile. +- `options` (optional) - List of DNF configuration options as "key=value" strings (e.g., `{ "install_weak_deps=false", "best=true" }`). Invalid options will cause the promise to fail. +- `handle` (optional) - CFEngine handle for the promise, recorded in DNF history for audit traceability. +- `comment` (optional) - CFEngine comment for the promise, recorded in DNF history for audit traceability. + +## Requirements + +- CFEngine 3.18 or later +- Python 3 +- DNF Python API (python3-dnf package) +- DNF/YUM package manager (RHEL 8+, Fedora, CentOS 8+) +- AppStream repositories configured diff --git a/promise-types/appstreams/appstreams.py b/promise-types/appstreams/appstreams.py new file mode 100644 index 00000000..7c3a4f68 --- /dev/null +++ b/promise-types/appstreams/appstreams.py @@ -0,0 +1,615 @@ +#!/usr/bin/python3 +# +# Custom promise type to manage AppStream modules +# Uses cfengine_module_library.py library. +# +# Use it in the policy like this: +# promise agent appstreams +# { +# interpreter => "/usr/bin/python3"; +# path => "$(sys.workdir)/modules/promises/appstreams.py"; +# } +# bundle agent main +# { +# appstreams: +# "nodejs" -> { "nodejs_app_server", "security_team" } +# handle => "main_nodejs_stream_20_installed", +# comment => "Pin nodejs to stream 20 before packages: promises run", +# meta => { "service:nodeapp", "stream:20" }, +# state => "installed", +# stream => "20"; +# +# "postgresql" +# handle => "main_postgresql_default", +# state => "default"; +# } +# +# Setting a handle is strongly recommended: it appears in the DNF history +# Comment field alongside the bundle and policy file, giving auditors a +# direct pointer back to the exact promise that made the change. + +import sys +import dnf +import dnf.exceptions +import re +from cfengine_module_library import PromiseModule, ValidationError, Result + +# Import ModuleBase if available (not available in test environment) +try: + import dnf.module.module_base +except (ImportError, ModuleNotFoundError): + dnf.module = None # type: ignore + + +class AppStreamsPromiseTypeModule(PromiseModule): + def __init__(self, **kwargs): + super(AppStreamsPromiseTypeModule, self).__init__( + name="appstreams_promise_module", version="0.0.1", **kwargs + ) + + self.add_attribute( + "state", + str, + required=False, + default="enabled", + validator=lambda x: self._validate_state(x), + ) + self.add_attribute( + "stream", + str, + required=False, + validator=lambda x: self._validate_identifier( + x, "stream name", required=False + ), + ) + self.add_attribute( + "profile", + str, + required=False, + validator=lambda x: self._validate_identifier( + x, "profile name", required=False + ), + ) + self.add_attribute( + "options", + list, + required=False, + default=[], + ) + + # Standard CFEngine promise attributes — passed through by the agent + # and used to populate the DNF history comment for audit traceability. + self.add_attribute("handle", str, required=False) + self.add_attribute("comment", str, required=False) + + def _validate_state(self, value): + accepted = ("enabled", "disabled", "installed", "removed", "default", "reset") + if value not in accepted: + accepted_str = "', '".join(accepted) + raise ValidationError(f"State attribute must be '{accepted_str}'") + + def _validate_module_name(self, name): + self._validate_identifier(name, "module name") + + def _validate_stream_name(self, stream): + self._validate_identifier(stream, "stream name", required=False) + + def _validate_profile_name(self, profile): + self._validate_identifier(profile, "profile name", required=False) + + def _validate_identifier(self, value, label, required=True): + if not required and not value: + return + if not re.fullmatch(r"[a-zA-Z0-9_.-]+", value): + raise ValidationError( + f"Invalid {label}: {value!r}. Only alphanumeric, underscore, " + f"dot, and dash characters are allowed." + ) + + def validate_promise(self, promiser, attributes, metadata): + if not isinstance(promiser, str): + raise ValidationError("Promiser must be of type string") + self._validate_identifier(promiser, "module name") + + def evaluate_promise(self, promiser, attributes, metadata): + module_name = promiser + state = attributes.get("state", "enabled") + stream = attributes.get("stream", None) + profile = attributes.get("profile", None) + options = attributes.get("options", []) + + # Build a descriptive argv so dnf history records a meaningful + # "Command Line" entry instead of leaving it blank. + _cmdline = [f"cfengine-appstreams {module_name!r} state={state!r}"] + if stream: + _cmdline.append(f"stream={stream!r}") + if profile: + _cmdline.append(f"profile={profile!r}") + if options: + _cmdline.append(f"options={options!r}") + _orig_argv, sys.argv = sys.argv, _cmdline + + base = dnf.Base() + try: + # Read configuration first so comment is set before plugins read it + base.conf.assumeyes = True + handle = attributes.get("handle", "") + cf_comment = attributes.get("comment", "") + extra = [] + if handle: + extra.append(f"handle: {handle}") + if cf_comment: + extra.append(f"comment: {cf_comment}") + extra_part = " | " + ", ".join(extra) if extra else "" + base.conf.comment = ( + f"CFEngine appstreams promise: {module_name} state={state}{extra_part}" + ) + + # Load DNF plugins so transactions are recorded like the CLI would. + # configure_plugins() is intentionally omitted: it opens a history + # entry unconditionally and base.close() would commit a spurious + # empty record on KEPT runs. init + pre_configure is sufficient for + # the transaction() hook to fire when do_transaction() is called. + base.init_plugins() + base.pre_configure_plugins() + + base.read_all_repos() + + # Force metadata expiry so DNF re-downloads repo metadata rather + # than using stale cache entries that may point to RPM paths from + # previously interrupted transactions that no longer exist on disk. + if base.repos: + for repo in base.repos.iter_enabled(): + repo.metadata_expire = 0 + + base.fill_sack(load_system_repo=True) + + if base.sack is None: + self.log_error("DNF sack is not available") + return Result.NOT_KEPT + if not hasattr(base.sack, "_moduleContainer"): + self.log_error("DNF sack has no module container") + return Result.NOT_KEPT + mpc = base.sack._moduleContainer + + # Resolve "default" stream/profile to concrete values + if stream == "default": + stream = mpc.getDefaultStream(module_name) + if not stream: + self.log_error(f"No default stream found for module {module_name}") + return Result.NOT_KEPT + self.log_verbose(f"Resolved 'default' stream to '{stream}'") + + if profile == "default": + resolved_stream = stream or mpc.getDefaultStream(module_name) + profiles = mpc.getDefaultProfiles(module_name, resolved_stream) + profile = profiles[0] if profiles else None + if not profile: + self.log_error(f"No default profile found for module {module_name}") + return Result.NOT_KEPT + self.log_verbose(f"Resolved 'default' profile to '{profile}'") + + current_state = self._get_module_state(mpc, module_name) + + if state == "enabled": + if current_state == "enabled": + already_correct = True + if stream: + try: + already_correct = ( + mpc.getEnabledStream(module_name) == stream + ) + except RuntimeError: + pass # cannot verify stream, assume correct + if already_correct: + self.log_verbose(f"Module {module_name} is already enabled") + return Result.KEPT + return self._enable_module(mpc, base, module_name, stream) + + elif state == "disabled": + if current_state == "disabled": + self.log_verbose(f"Module {module_name} is already disabled") + return Result.KEPT + return self._disable_module(mpc, base, module_name) + + elif state == "installed": + # Check if we need to switch streams + try: + enabled_stream = mpc.getEnabledStream(module_name) + if stream and enabled_stream and enabled_stream != stream: + # Stream switch needed + self.log_info( + f"Switching module {module_name} from stream " + f"{enabled_stream} to {stream}" + ) + return self._switch_module( + mpc, base, module_name, stream, profile, options + ) + except RuntimeError: + # Module not enabled yet, proceed with normal install + pass + + if self._is_module_installed_with_packages( + mpc, base, module_name, stream, profile + ): + self.log_verbose( + f"Module {module_name} (stream: {stream}, " + f"profile: {profile}) is already present" + ) + return Result.KEPT + return self._install_module( + mpc, base, module_name, stream, profile, options + ) + + elif state == "removed": + if current_state in ("removed", "disabled"): + self.log_verbose( + f"Module {module_name} is already absent or disabled" + ) + return Result.KEPT + return self._remove_module(mpc, base, module_name, stream, profile) + + elif state in ("default", "reset"): + return self._reset_module(mpc, base, module_name) + + self.log_error(f"Unexpected state '{state}' for module {module_name}") + return Result.NOT_KEPT + finally: + base.close() + sys.argv = _orig_argv + + def _get_module_state(self, mpc, module_name): + state = mpc.getModuleState(module_name) + if state == mpc.ModuleState_ENABLED: + return "enabled" + elif state == mpc.ModuleState_DISABLED: + return "disabled" + elif state == mpc.ModuleState_INSTALLED: + return "installed" + return "removed" + + def _is_module_installed_with_packages( + self, mpc, base, module_name, stream, profile_name + ): + """Check if the module packages/profiles are installed on the system""" + # Check stream + try: + enabled_stream = mpc.getEnabledStream(module_name) + except RuntimeError: + # RuntimeError is raised by libdnf if the module is unknown + return False + + if stream and enabled_stream != stream: + return False + + target_stream = stream or enabled_stream + if not target_stream: + return False + + # Check profile + if not profile_name: + profiles = mpc.getDefaultProfiles(module_name, target_stream) + profile_name = profiles[0] if profiles else None + + if profile_name: + try: + if profile_name not in mpc.getInstalledProfiles(module_name): + return False + except RuntimeError: + # RuntimeError is raised by libdnf if the module is unknown + return False + + # Verify the profile's packages are actually installed as RPMs. + # DNF's module database can mark a profile as installed even if the + # RPM transaction failed (e.g. due to a stale cache error), leaving + # the module state inconsistent with the actual system state. + packages = self._get_profile_packages( + mpc, module_name, target_stream, profile_name + ) + if packages: + installed_query = base.sack.query().installed() + upgrade_query = base.sack.query().upgrades() + for pkg in packages: + if not installed_query.filter(name=pkg): + self.log_verbose( + f"Profile '{profile_name}' is marked installed but " + f"package '{pkg}' is not present on the system" + ) + return False + # If an upgrade is available the package is from an older + # stream — treat as not converged so _install_module runs + # and upgrades to the enabled stream's version. + if upgrade_query.filter(name=pkg): + self.log_verbose( + f"Package '{pkg}' has an available upgrade from " + f"stream '{target_stream}', needs repair" + ) + return False + + return True + + def _enable_module(self, mpc, base, module_name, stream): + """Enable a module stream without installing any packages.""" + target_stream = stream or mpc.getDefaultStream(module_name) + if not target_stream: + self.log_error( + f"No stream specified and no default stream found for {module_name}" + ) + return Result.NOT_KEPT + + mpc.enable(module_name, target_stream) + mpc.save() + mpc.moduleDefaultsResolve() + base.resolve() + base.do_transaction() + if mpc.isEnabled(module_name, target_stream): + self.log_info(f"Module {module_name}:{target_stream} enabled successfully") + return Result.REPAIRED + else: + self.log_error(f"Failed to enable module {module_name}:{target_stream}") + return Result.NOT_KEPT + + def _disable_module(self, mpc, base, module_name): + """Disable a module stream so it cannot be enabled by dependency resolution.""" + mpc.disable(module_name) + mpc.save() + base.resolve() + base.do_transaction() + if mpc.isDisabled(module_name): + self.log_info(f"Module {module_name} disabled successfully") + return Result.REPAIRED + else: + self.log_error(f"Failed to disable module {module_name}") + return Result.NOT_KEPT + + def _get_profile_packages(self, mpc, module_name, stream, profile_name): + # mpc.query(name) returns a vector of ModulePackage objects + for module in mpc.query(module_name): + if module.getStream() == stream: + for profile in module.getProfiles(): + if profile.getName() == profile_name: + return profile.getContent() + return [] + + def _log_failed_packages(self, failed_packages): + for pkg, error in failed_packages: + self.log_error(f" Package {pkg} failed: {error}") + + def _apply_dnf_options(self, base, options): + """Apply DNF configuration options, raising ConfigError on invalid options""" + if not options: + return + + for option in options: + if "=" in option: + key, value = option.split("=", 1) + key = key.strip() + value = value.strip() + + # Raises dnf.exceptions.ConfigError if option is invalid + base.conf.set_or_append_opt_value(key, value) + self.log_verbose(f"Set DNF option: {key}={value}") + + def _switch_module(self, mpc, base, module_name, stream, profile, options=None): + """Switch a module to a different stream using ModuleBase.switch_to()""" + if options is None: + options = [] + + # Apply DNF configuration options + try: + self._apply_dnf_options(base, options) + except dnf.exceptions.ConfigError as e: + self.log_error(f"Invalid DNF option: {e}") + return Result.NOT_KEPT + + if not stream: + self.log_error("Stream must be specified for module switch") + return Result.NOT_KEPT + + if not profile: + profile = mpc.getDefaultProfiles(module_name, stream) + profile = profile[0] if profile else None + + if not profile: + self.log_error( + f"No profile specified and no default found for {module_name}:{stream}" + ) + return Result.NOT_KEPT + + # Use ModuleBase API to switch streams + module_spec = f"{module_name}:{stream}/{profile}" + self.log_verbose(f"Switching to module spec: {module_spec}") + + # Build command line for DNF history (shown in dnf history list) + cmdline_parts = ["module", "switch-to", "-y", module_spec] + if options: + for opt in options: + cmdline_parts.append(f"--setopt={opt}") + base.args = cmdline_parts + + try: + # Create ModuleBase wrapper around base + module_base = dnf.module.module_base.ModuleBase(base) + module_base.switch_to([module_spec]) + except dnf.exceptions.Error as e: + self.log_error(f"Failed to switch module {module_spec}: {e}") + return Result.NOT_KEPT + + # Resolve and execute transaction + base.resolve() + + # Download packages before transaction (following DNF CLI pattern) + pkgs_to_download = list(base.transaction.install_set) + if pkgs_to_download: + base.download_packages(pkgs_to_download) + + base.do_transaction() + + # Verify switch succeeded + try: + enabled_stream = mpc.getEnabledStream(module_name) + except RuntimeError: + self.log_error( + f"Failed to get enabled stream for {module_name} after switch" + ) + return Result.NOT_KEPT + + if enabled_stream != stream: + self.log_error( + f"Module {module_name} stream is {enabled_stream}, expected {stream}" + ) + return Result.NOT_KEPT + + try: + installed_profiles = mpc.getInstalledProfiles(module_name) + except RuntimeError: + self.log_error( + f"Failed to get installed profiles for {module_name} after switch" + ) + return Result.NOT_KEPT + + if profile not in installed_profiles: + self.log_error( + f"Profile {profile} not in installed profiles {installed_profiles}" + ) + return Result.NOT_KEPT + + self.log_info(f"Module {module_name}:{stream}/{profile} switched successfully") + return Result.REPAIRED + + def _install_module(self, mpc, base, module_name, stream, profile, options=None): + """Enable a module stream and install the given (or default) profile's packages.""" + # Apply DNF options if specified + try: + self._apply_dnf_options(base, options) + except dnf.exceptions.ConfigError as e: + self.log_error(f"Invalid DNF option: {e}") + return Result.NOT_KEPT + + if not stream: + try: + stream = mpc.getEnabledStream(module_name) + except RuntimeError: + pass + stream = stream or mpc.getDefaultStream(module_name) + + if not profile: + profiles = mpc.getDefaultProfiles(module_name, stream) + profile = profiles[0] if profiles else None + + if not profile: + self.log_error( + f"No profile specified and no default found for {module_name}:{stream}" + ) + return Result.NOT_KEPT + + # Use ModuleBase API for proper module context + spec = f"{module_name}:{stream}/{profile}" + + # Build command line for DNF history (shown in dnf history list) + cmdline_parts = ["module", "install", "-y", spec] + if options: + for opt in options: + cmdline_parts.append(f"--setopt={opt}") + base.args = cmdline_parts + + try: + module_base = dnf.module.module_base.ModuleBase(base) + module_base.install([spec]) + except dnf.exceptions.Error as e: + self.log_error(f"Failed to install module {spec}: {e}") + return Result.NOT_KEPT + + base.resolve() + + # Explicitly download packages before the transaction. Without this, + # do_transaction() uses paths resolved during fill_sack(), which may + # point to stale entries from a previously interrupted transaction that + # no longer exist on disk, causing a FileNotFoundError. + pkgs_to_download = list(base.transaction.install_set) + if pkgs_to_download: + base.download_packages(pkgs_to_download) + + base.do_transaction() + + # Verify using the module database only — not the RPM sack, which was + # populated before the transaction and cannot see newly installed packages. + try: + profile_installed = profile in mpc.getInstalledProfiles(module_name) + except RuntimeError: + profile_installed = False + + if profile_installed: + self.log_info( + f"Module {module_name}:{stream}/{profile} installed successfully" + ) + return Result.REPAIRED + else: + self.log_error(f"Failed to install module {module_name}:{stream}/{profile}") + return Result.NOT_KEPT + + def _remove_module(self, mpc, base, module_name, stream, profile): + """Uninstall profile packages and leave the stream in enabled (pinned) state.""" + if not stream: + try: + target_stream = mpc.getEnabledStream(module_name) + except RuntimeError: + target_stream = None + else: + target_stream = stream + + if not target_stream: + self.log_verbose(f"No active stream for {module_name}, nothing to remove") + return Result.KEPT + + failed_packages = [] + profiles_to_remove = ( + [profile] if profile else mpc.getInstalledProfiles(module_name) + ) + for p in profiles_to_remove: + mpc.uninstall(module_name, target_stream, p) + for pkg in self._get_profile_packages(mpc, module_name, target_stream, p): + try: + base.remove(pkg) + except dnf.exceptions.Error as e: + self.log_verbose(f"Failed to remove package {pkg}: {e}") + failed_packages.append((pkg, str(e))) + + mpc.save() + base.resolve(allow_erasing=True) + base.do_transaction() + + # Verify removal succeeded. After uninstalling a profile, DNF leaves + # the stream in "enabled" state (stream pinned, no packages installed). + # "removed" only occurs when the module is fully reset. Accept any + # state other than "installed" as success. + if self._get_module_state(mpc, module_name) != "installed": + self.log_info(f"Module {module_name} removed successfully") + return Result.REPAIRED + else: + self.log_error(f"Failed to remove module {module_name}") + self._log_failed_packages(failed_packages) + return Result.NOT_KEPT + + def _reset_module(self, mpc, base, module_name): + """Reset a module to factory state — no stream pinned, no enabled/disabled flag.""" + if mpc.getModuleState(module_name) == mpc.ModuleState_DEFAULT: + self.log_verbose( + f"Module {module_name} is already in default (reset) state" + ) + return Result.KEPT + + mpc.reset(module_name) + mpc.save() + base.resolve() + base.do_transaction() + + # The in-memory mpc is not refreshed after do_transaction(), so + # getModuleState() still reflects the pre-reset state. Trust the + # operation — if no exception was raised the reset succeeded. + self.log_info(f"Module {module_name} reset successfully") + return Result.REPAIRED + + +if __name__ == "__main__": + AppStreamsPromiseTypeModule().start() diff --git a/promise-types/appstreams/init.cf b/promise-types/appstreams/init.cf new file mode 100644 index 00000000..bad84430 --- /dev/null +++ b/promise-types/appstreams/init.cf @@ -0,0 +1,6 @@ +promise agent appstreams +# @brief Define appstreams promise type +{ + path => "$(sys.workdir)/modules/promises/appstreams.py"; + interpreter => "/usr/bin/python3"; +} diff --git a/promise-types/appstreams/test_appstreams.py b/promise-types/appstreams/test_appstreams.py new file mode 100644 index 00000000..1fe74711 --- /dev/null +++ b/promise-types/appstreams/test_appstreams.py @@ -0,0 +1,114 @@ +import sys +import os +import pytest +from unittest.mock import MagicMock + +# Mock dnf module before importing the promise module +mock_dnf = MagicMock() +mock_dnf.exceptions = MagicMock() +sys.modules["dnf"] = mock_dnf +sys.modules["dnf.exceptions"] = mock_dnf.exceptions + +# Add library path +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), "..", "..", "libraries", "python") +) +# Add module path +sys.path.insert(0, os.path.dirname(__file__)) + +from appstreams import AppStreamsPromiseTypeModule # noqa: E402 +from cfengine_module_library import ValidationError # noqa: E402 + + +@pytest.fixture +def module(): + return AppStreamsPromiseTypeModule() + + +def test_validation_valid_attributes(module): + """Test validation of valid module attributes""" + module.validate_promise("nodejs", {"state": "enabled", "stream": "12"}, {}) + + +def test_validation_invalid_module_name(module): + """Test validation of invalid module name""" + with pytest.raises(ValidationError) as excinfo: + module.validate_promise("nodejs; echo hi", {"state": "enabled"}, {}) + assert "Invalid module name" in str(excinfo.value) + + +@pytest.mark.parametrize( + "name", ["nodejs", "python3.6", "python36", "postgresql", "maven", "httpd"] +) +def test_module_name_validation_valid(module, name): + """Test module name validation with valid names""" + module._validate_module_name(name) + + +@pytest.mark.parametrize( + "name", ["nodejs;echo", "python36&&", "postgresql|", "maven>", "httpd<"] +) +def test_module_name_validation_invalid(module, name): + """Test module name validation with invalid names""" + with pytest.raises(ValidationError): + module._validate_module_name(name) + + +@pytest.mark.parametrize( + "stream", ["12", "14", "3.6", "1.14", "latest", "stable", "default"] +) +def test_stream_name_validation_valid(module, stream): + """Test stream name validation with valid names""" + module._validate_stream_name(stream) + + +@pytest.mark.parametrize("stream", ["12;echo", "14&&", "3.6|", "latest>", "stable<"]) +def test_stream_name_validation_invalid(module, stream): + """Test stream name validation with invalid names""" + with pytest.raises(ValidationError): + module._validate_stream_name(stream) + + +@pytest.mark.parametrize( + "state", ["enabled", "disabled", "installed", "removed", "default", "reset"] +) +def test_state_validation_valid(module, state): + """Test state validation with valid states""" + module._validate_state(state) + + +@pytest.mark.parametrize( + "state", + [ + "active", + "inactive", + "enable", + "disable", + "install", + "remove", + "present", + "absent", + ], +) +def test_state_validation_invalid(module, state): + """Test state validation with invalid states""" + with pytest.raises(ValidationError): + module._validate_state(state) + + +def test_state_parsing_method_exists(module): + """Test that the state parsing method exists""" + assert hasattr(module, "_get_module_state") + + +@pytest.mark.parametrize("profile", ["common", "minimal", "server", "default", "1.0"]) +def test_profile_name_validation_valid(module, profile): + """Test profile name validation with valid names""" + module._validate_profile_name(profile) + + +@pytest.mark.parametrize("profile", ["common;echo", "minimal&&", "server|", "default>"]) +def test_profile_name_validation_invalid(module, profile): + """Test profile name validation with invalid names""" + with pytest.raises(ValidationError): + module._validate_profile_name(profile) diff --git a/promise-types/appstreams/test_appstreams_logic.py b/promise-types/appstreams/test_appstreams_logic.py new file mode 100644 index 00000000..8ceaf604 --- /dev/null +++ b/promise-types/appstreams/test_appstreams_logic.py @@ -0,0 +1,321 @@ +import sys +import os +import pytest +from unittest.mock import MagicMock + +# Add library path +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), "..", "..", "libraries", "python") +) +# Add module path +sys.path.insert(0, os.path.dirname(__file__)) + +# Mock dnf module before importing the promise module +mock_dnf = MagicMock() +mock_dnf.exceptions = MagicMock() +mock_dnf.module = MagicMock() +mock_dnf.module.module_base = MagicMock() +sys.modules["dnf"] = mock_dnf +sys.modules["dnf.exceptions"] = mock_dnf.exceptions +sys.modules["dnf.module"] = mock_dnf.module +sys.modules["dnf.module.module_base"] = mock_dnf.module.module_base + +import appstreams as appstreams_module # noqa: E402 + +appstreams_module.dnf = mock_dnf + +from appstreams import AppStreamsPromiseTypeModule # noqa: E402 +from cfengine_module_library import ValidationError, Result # noqa: E402 + + +@pytest.fixture +def module(): + # Reset mocks + mock_dnf.reset_mock() + if hasattr(mock_dnf.Base, "return_value"): + mock_dnf.Base.return_value.reset_mock() + + mod = AppStreamsPromiseTypeModule() + mod._log_level = "info" + mod._out = MagicMock() + return mod + + +@pytest.fixture +def mock_mpc(): + # Setup the ModulePackageContainer mock + mpc = MagicMock() + # Setup constants + mpc.ModuleState_ENABLED = 1 + mpc.ModuleState_DISABLED = 2 + mpc.ModuleState_INSTALLED = 3 + mpc.ModuleState_DEFAULT = 0 + return mpc + + +@pytest.fixture +def mock_base(mock_mpc): + base = mock_dnf.Base.return_value + base.sack._moduleContainer = mock_mpc + return base + + +def test_harness_setup(module): + """Verify the test harness is working""" + assert module is not None + assert module.name == "appstreams_promise_module" + + +def test_enable_module_already_enabled(module, mock_base, mock_mpc): + """Test enabling a module that is already enabled (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "12" + + result = module.evaluate_promise("nodejs", {"state": "enabled", "stream": "12"}, {}) + assert result == Result.KEPT + + +def test_enable_module_repaired(module, mock_base, mock_mpc): + """Test enabling a module that is disabled (REPAIRED)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DISABLED + mock_mpc.isEnabled.return_value = True # After enable() called + + result = module.evaluate_promise("nodejs", {"state": "enabled", "stream": "12"}, {}) + + mock_mpc.enable.assert_called_with("nodejs", "12") + assert result == Result.REPAIRED + + +def test_disable_module_already_disabled(module, mock_base, mock_mpc): + """Test disabling a module that is already disabled (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DISABLED + + result = module.evaluate_promise( + "nodejs", {"state": "disabled", "stream": "12"}, {} + ) + assert result == Result.KEPT + + +def test_disable_module_repaired(module, mock_base, mock_mpc): + """Test disabling a module that is enabled (REPAIRED)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.isDisabled.return_value = True # After disable() called + + result = module.evaluate_promise( + "nodejs", {"state": "disabled", "stream": "12"}, {} + ) + + mock_mpc.disable.assert_called_with("nodejs") + assert result == Result.REPAIRED + + +def test_install_profile_repaired(module, mock_base, mock_mpc): + """Test installing a specific profile using 'installed' state (REPAIRED)""" + # Initial state: enabled but not fully installed with profile + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "12" + # First call (pre-install check) returns [], second call (post-install verify) returns ["common"] + mock_mpc.getInstalledProfiles.side_effect = [[], ["common"]] + + result = module.evaluate_promise( + "nodejs", {"state": "installed", "stream": "12", "profile": "common"}, {} + ) + + # We now use ModuleBase API instead of mpc.install + # Verify the transaction was executed + mock_base.resolve.assert_called() + mock_base.do_transaction.assert_called() + assert result == Result.REPAIRED + + +def test_remove_module_repaired(module, mock_base, mock_mpc): + """Test removing a module using 'removed' state (REPAIRED)""" + # First call (current state check) returns INSTALLED, second (post-remove verify) returns DEFAULT + mock_mpc.getModuleState.side_effect = [ + mock_mpc.ModuleState_INSTALLED, + mock_mpc.ModuleState_DEFAULT, + ] + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = ["common"] + + # For removal, we also need package query to mock explicit package removal + mock_module_obj = MagicMock() + mock_module_obj.getStream.return_value = "12" + mock_profile_obj = MagicMock() + mock_profile_obj.getName.return_value = "common" + mock_profile_obj.getContent.return_value = ["pkg1"] + mock_module_obj.getProfiles.return_value = [mock_profile_obj] + mock_mpc.query.return_value = [mock_module_obj] + + result = module.evaluate_promise("nodejs", {"state": "removed", "stream": "12"}, {}) + + # Logic in _remove_module calls uninstall for each installed profile if no profile specified + mock_mpc.uninstall.assert_called() + assert result == Result.REPAIRED + + +def test_install_profile_idempotency_success(module, mock_base, mock_mpc): + """Test installing a profile that is already present (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_INSTALLED + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = ["common"] + + result = module.evaluate_promise( + "nodejs", {"state": "installed", "stream": "12", "profile": "common"}, {} + ) + + assert result == Result.KEPT + + +def test_reset_module_repaired(module, mock_base, mock_mpc): + """Test resetting a module to default state (REPAIRED)""" + # evaluate_promise calls _get_module_state first, then _reset_module calls it twice more + mock_mpc.getModuleState.side_effect = [ + mock_mpc.ModuleState_ENABLED, # evaluate_promise current-state check + mock_mpc.ModuleState_ENABLED, # _reset_module early-exit check + mock_mpc.ModuleState_DEFAULT, # _reset_module post-reset verification + ] + + result = module.evaluate_promise("nodejs", {"state": "default", "stream": "12"}, {}) + + mock_mpc.reset.assert_called_with("nodejs") + assert result == Result.REPAIRED + + +def test_stream_default_resolution(module, mock_base, mock_mpc): + """Test resolving stream => 'default'""" + mock_mpc.getDefaultStream.return_value = "12" + + # State check uses resolved stream + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "12" + + result = module.evaluate_promise( + "nodejs", {"state": "enabled", "stream": "default"}, {} + ) + + assert result == Result.KEPT + + +def test_profile_default_resolution(module, mock_base, mock_mpc): + """Test resolving profile => 'default'""" + mock_mpc.getDefaultStream.return_value = "12" + mock_mpc.getDefaultProfiles.return_value = ["default_prof"] + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_INSTALLED + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = ["default_prof"] + + result = module.evaluate_promise( + "nodejs", {"state": "installed", "stream": "12", "profile": "default"}, {} + ) + + assert result == Result.KEPT + + +def test_invalid_aliases(module): + """Verify that aliases 'install' and 'remove' are invalid""" + + # Test 'install' + with pytest.raises(ValidationError): + module._validate_state("install") + + # Test 'remove' + with pytest.raises(ValidationError): + module._validate_state("remove") + + +def test_get_module_state_logic(module, mock_mpc): + """Test the logic of _get_module_state""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + assert module._get_module_state(mock_mpc, "nodejs") == "enabled" + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DISABLED + assert module._get_module_state(mock_mpc, "nodejs") == "disabled" + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_INSTALLED + assert module._get_module_state(mock_mpc, "nodejs") == "installed" + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DEFAULT + assert module._get_module_state(mock_mpc, "nodejs") == "removed" + + mock_mpc.getModuleState.return_value = 999 # Unknown state + assert module._get_module_state(mock_mpc, "nodejs") == "removed" + + +def test_remove_already_removed(module, mock_base, mock_mpc): + """Test removing a module that is already removed (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DEFAULT + result = module.evaluate_promise("nodejs", {"state": "removed"}, {}) + assert result == Result.KEPT + + +def test_remove_already_disabled(module, mock_base, mock_mpc): + """Test removing a module that is already disabled (KEPT)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_DISABLED + result = module.evaluate_promise("nodejs", {"state": "removed"}, {}) + assert result == Result.KEPT + + +def test_enable_wrong_stream_repaired(module, mock_base, mock_mpc): + """Test enabling a module that is enabled but with wrong stream (REPAIRED)""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "10" + mock_mpc.isEnabled.return_value = True + result = module.evaluate_promise("nodejs", {"state": "enabled", "stream": "12"}, {}) + mock_mpc.enable.assert_called_with("nodejs", "12") + assert result == Result.REPAIRED + + +def test_stream_default_not_found(module, mock_base, mock_mpc): + """Test stream => 'default' when no default stream exists (NOT_KEPT)""" + mock_mpc.getDefaultStream.return_value = None + result = module.evaluate_promise( + "nodejs", {"state": "enabled", "stream": "default"}, {} + ) + assert result == Result.NOT_KEPT + + +def test_profile_default_not_found(module, mock_base, mock_mpc): + """Test profile => 'default' when no default profile exists (NOT_KEPT)""" + mock_mpc.getDefaultStream.return_value = "12" + mock_mpc.getDefaultProfiles.return_value = [] + result = module.evaluate_promise( + "nodejs", {"state": "installed", "stream": "12", "profile": "default"}, {} + ) + assert result == Result.NOT_KEPT + + +def test_invalid_dnf_option_not_kept(module, mock_base, mock_mpc): + """Test that invalid DNF options cause NOT_KEPT (ConfigError from DNF)""" + # Mock ConfigError to be raised when invalid option is set + mock_base.conf.set_or_append_opt_value.side_effect = ( + mock_dnf.exceptions.ConfigError('Cannot set "invalid_option" to "value"') + ) + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = [] + + result = module.evaluate_promise( + "nodejs", + { + "state": "installed", + "stream": "12", + "profile": "common", + "options": ["invalid_option=value"], + }, + {}, + ) + + # Should fail because invalid option raises ConfigError + assert result == Result.NOT_KEPT + + +def test_remove_unknown_module_runtime_error(module, mock_base, mock_mpc): + """Test removing a module when getEnabledStream raises RuntimeError""" + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.side_effect = RuntimeError("No such module") + result = module.evaluate_promise("unknown_mod", {"state": "removed"}, {}) + # With no stream and getEnabledStream failing, target_stream is None, so KEPT + assert result == Result.KEPT diff --git a/promise-types/appstreams/test_integration.sh b/promise-types/appstreams/test_integration.sh new file mode 100755 index 00000000..2ba938f3 --- /dev/null +++ b/promise-types/appstreams/test_integration.sh @@ -0,0 +1,248 @@ +#!/bin/bash +# Integration test for the appstreams promise type. +# Requires a bootstrapped CFEngine agent on Rocky Linux 8 or 9. +# +# Usage: +# ./test_integration.sh +# +# The test policy (test_appstreams_coverage.cf) must be deployed to +# /var/cfengine/inputs/services/cfbs/ and the appstreams promise type +# must be registered in services/init.cf before running this script. + +set -euo pipefail + +PASS=0 +FAIL=0 +BUNDLE="test_appstreams_coverage" +ROLE="role_node_js_app_server_enabled" + +pass() { echo " PASS: $*"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); } + +run_agent() { + local classes="$1" + cf-agent -KI -b "$BUNDLE" -D "$ROLE,$classes" 2>&1 || true +} + +get_history_id() { + dnf history list 2>/dev/null | awk '/^[[:space:]]*[0-9]/ {print $1; exit}' | tr -d ' ' || true +} + +# Assert a new DNF history transaction was created since before_id. +# If pkg is provided, verify the transaction mentions that package. +assert_history_entry() { + local before_id="$1" desc="$2" pkg="${3:-}" + local after_id + after_id=$(get_history_id) + if [ "$after_id" = "$before_id" ]; then + fail "dnf history: no transaction recorded ($desc)" + return + fi + pass "dnf history: transaction $after_id recorded ($desc)" + if [ -n "$pkg" ]; then + if dnf history info "$after_id" 2>/dev/null | grep -qi "$pkg"; then + pass "dnf history: transaction $after_id mentions $pkg" + else + fail "dnf history: transaction $after_id does not mention $pkg" + dnf history info "$after_id" 2>/dev/null \ + | grep -iE "Package|Install|Remove|Upgrade|Module" \ + | head -10 | sed 's/^/ /' + fi + fi +} + +# Assert no new DNF history transaction was created since before_id. +assert_no_history_entry() { + local before_id="$1" desc="$2" + local after_id + after_id=$(get_history_id) + if [ "$after_id" = "$before_id" ]; then + pass "dnf history: no transaction for idempotent run ($desc)" + else + fail "dnf history: unexpected transaction $after_id for idempotent run ($desc)" + fi +} + +# Assert the Comment field of a history transaction contains a pattern. +assert_history_comment() { + local id="$1" pattern="$2" + if dnf history info "$id" 2>/dev/null | grep -qP "^Comment\s*:.*$pattern"; then + pass "dnf history comment contains: $pattern" + else + fail "dnf history comment missing: $pattern" + dnf history info "$id" 2>/dev/null | grep "^Comment" | sed 's/^/ /' + fi +} + +assert_repaired() { + local output="$1" pattern="$2" + if echo "$output" | grep -q "info:.*$pattern"; then + pass "$pattern" + else + fail "expected repair: $pattern" + echo "$output" | grep -E "info:|error:|CRITICAL" | sed 's/^/ /' + fi +} + +assert_kept() { + local output="$1" + if echo "$output" | grep -qE "^\s*(info:.*Repaired|error:|CRITICAL)"; then + fail "expected KEPT but got repairs or errors" + echo "$output" | grep -E "info:|error:|CRITICAL" | sed 's/^/ /' + else + pass "idempotent (no repairs)" + fi +} + +assert_rpm_installed() { + local pkg="$1" + if rpm -q "$pkg" &>/dev/null; then + pass "$pkg is installed" + else + fail "$pkg is not installed" + fi +} + +assert_rpm_absent() { + local pkg="$1" + if ! rpm -q "$pkg" &>/dev/null; then + pass "$pkg is absent" + else + fail "$pkg should not be installed" + fi +} + +assert_module_stream() { + local module="$1" stream="$2" marker="$3" desc="$4" + if dnf module list "$module" 2>/dev/null | grep -qP "$stream\s.*\[$marker\]"; then + pass "$desc" + else + fail "$desc" + dnf module list "$module" 2>/dev/null | grep "$module" | sed 's/^/ /' + fi +} + +assert_module_default() { + local module="$1" + # Filter the Hint line (which contains [e], [x], [i] as legend text) + local listing + listing=$(dnf module list "$module" 2>/dev/null | grep -v "^Hint:") + if echo "$listing" | grep -qP "$module\s" && \ + ! echo "$listing" | grep -qP "\[e\]|\[x\]|\[i\]"; then + pass "$module is in default state (no markers)" + else + fail "$module should have no stream markers" + echo "$listing" | grep "$module" | sed 's/^/ /' + fi +} + +echo "========================================" +echo " appstreams promise type integration test" +echo "========================================" +echo + +# ------------------------------------------------------------------ +echo "Setup: resetting module state to a known baseline..." +dnf module reset ruby -y &>/dev/null || true +dnf module reset postgresql -y &>/dev/null || true +dnf remove postgresql-server -y &>/dev/null || true +echo + +# ------------------------------------------------------------------ +echo "Phase 1: enabled" +out=$(run_agent phase_enabled) +assert_repaired "$out" "ruby.*enabled" +assert_module_stream ruby 3.3 e "ruby:3.3 is enabled" +echo " idempotency check" +out=$(run_agent phase_enabled) +assert_kept "$out" +echo + +# ------------------------------------------------------------------ +echo "Phase 2: installed (explicit profile)" +hid=$(get_history_id) +out=$(run_agent phase_installed) +assert_repaired "$out" "postgresql.*installed" +assert_rpm_installed postgresql-server +assert_history_entry "$hid" "installed postgresql:15/server" "postgresql" +last_id=$(get_history_id) +assert_history_comment "$last_id" "test_appstreams_coverage_postgresql_15_server_installed" +assert_history_comment "$last_id" "Install postgresql 15 server profile" + +echo " idempotency check" +hid=$(get_history_id) +out=$(run_agent phase_installed) +assert_kept "$out" +assert_no_history_entry "$hid" "installed postgresql:15/server" +echo + +# ------------------------------------------------------------------ +echo "Phase 3: disabled" +out=$(run_agent phase_disabled) +assert_repaired "$out" "ruby.*disabled" +assert_module_stream ruby 3.3 x "ruby:3.3 is disabled" +echo " idempotency check" +out=$(run_agent phase_disabled) +assert_kept "$out" +echo + +# ------------------------------------------------------------------ +echo "Phase 4: removed" +hid=$(get_history_id) +out=$(run_agent phase_removed) +assert_repaired "$out" "postgresql.*removed" +assert_rpm_absent postgresql-server +assert_history_entry "$hid" "removed postgresql:15" "postgresql" +last_id=$(get_history_id) +assert_history_comment "$last_id" "test_appstreams_coverage_postgresql_15_removed" + +echo " idempotency check" +hid=$(get_history_id) +out=$(run_agent phase_removed) +assert_kept "$out" +assert_no_history_entry "$hid" "removed postgresql:15" +echo + +# ------------------------------------------------------------------ +echo "Phase 5: reset" +out=$(run_agent phase_reset) +assert_repaired "$out" "ruby.*reset" +assert_module_default ruby +echo " idempotency check" +out=$(run_agent phase_reset) +assert_kept "$out" +echo + +# ------------------------------------------------------------------ +echo "Phase 6: stream switch (nodejs 20 -> 22)" +# Ensure we start from stream 20 for a meaningful switch test. +# dnf module install won't downgrade RPMs, so explicitly remove and reinstall. +dnf module reset nodejs -y &>/dev/null || true +dnf remove nodejs npm -y &>/dev/null || true +dnf module install nodejs:20/common -y &>/dev/null || true + +hid=$(get_history_id) +out=$(run_agent phase_stream_switch) +assert_repaired "$out" "nodejs.*installed" +node_ver=$(node --version 2>/dev/null || echo "not found") +if echo "$node_ver" | grep -q "^v22\."; then + pass "node version is $node_ver (stream 22)" +else + fail "expected v22.x, got $node_ver" +fi +assert_history_entry "$hid" "stream switch nodejs 20->22" "nodejs" +last_id=$(get_history_id) +assert_history_comment "$last_id" "test_appstreams_coverage_nodejs_22_stream_switch" + +echo " idempotency check" +hid=$(get_history_id) +out=$(run_agent phase_stream_switch) +assert_kept "$out" +assert_no_history_entry "$hid" "stream switch nodejs 20->22" +echo + +# ------------------------------------------------------------------ +echo "========================================" +echo " Results: $PASS passed, $FAIL failed" +echo "========================================" +[ "$FAIL" -eq 0 ] diff --git a/promise-types/git/README.md b/promise-types/git/README.md index f15964f8..e493152a 100644 --- a/promise-types/git/README.md +++ b/promise-types/git/README.md @@ -1,9 +1,4 @@ -# git promise type - -## Synopsis - -* *Name*: `git` -* *Description*: Manage git checkouts of repositories to deploy files or software. +The `git` promise type enables writing concise policy for cloning a git repo and keeping it updated. ## Requirements @@ -65,11 +60,37 @@ bundle agent main } ``` -## Authors +## Authentication + +This module will set the `HOME` environment variable if it is not set already based on the user running `cf-agent`, typically `root`. + +In order to add authentication you can use [gitcredentials](https://git-scm.com/docs/gitcredentials). + +An example of this usage would be to have two files in `$HOME`: `.gitconfig` and `.git-credentials`. + +- `.gitconfig` +```sh +[credential] + helper = store +``` -This software was created by the team at [Northern.tech AS](https://northern.tech), with many contributions from the community. Thanks everyone! +- `.git-credentials` + +Using the `store` helper places the username and password in plaintext in `$HOME/.git-credentials`. + +Here is an explanation of that file [storage format](https://git-scm.com/docs/git-credential-store#_storage_format): + +> The `.git-credentials` file is stored in plaintext. Each credential is stored on its own line as a URL like: + +```text +https://user:pass@example.com +``` +> No other kinds of lines (e.g. empty lines or comment lines) are allowed in the file, even though some may be silently ignored. Do not view or edit the file with editors. + +## Authors -[CFEngine](https://cfengine.com) is sponsored by [Northern.tech AS](https://northern.tech) +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! ## Contribute diff --git a/promise-types/git/example.cf b/promise-types/git/example.cf index ea8f9b64..7d710d1b 100644 --- a/promise-types/git/example.cf +++ b/promise-types/git/example.cf @@ -9,7 +9,7 @@ bundle agent main { git: "starter_pack_repo" - repository => "https://github.com/cfengine/starter_pack", + repository => "https://github.com/cfengine/starter_pack", destination => "$(this.promise_dirname)/starter-pack", - version => "master"; + version => "master"; } diff --git a/promise-types/git/git.py b/promise-types/git/git.py index e472948f..9ed4cc5a 100644 --- a/promise-types/git/git.py +++ b/promise-types/git/git.py @@ -1,15 +1,21 @@ import os import subprocess +from pathlib import Path from typing import Dict, List, Optional -from cfengine import PromiseModule, ValidationError, Result +from cfengine_module_library import ( + PromiseModule, + ValidationError, + Result, + AttributeObject, +) class GitPromiseTypeModule(PromiseModule): def __init__(self, **kwargs): super(GitPromiseTypeModule, self).__init__( - "git_promise_module", "0.2.2", **kwargs + "git_promise_module", "0.0.0", **kwargs ) def destination_must_be_absolute(v): @@ -42,7 +48,7 @@ def depth_must_be_zero_or_more(v): self.add_attribute("update", bool, default=True) self.add_attribute("version", str, default="HEAD") - def evaluate_promise(self, promiser: str, attributes: Dict, meta: Dict): + def evaluate_promise(self, promiser: str, attributes: Dict, metadata: Dict): safe_promiser = promiser.replace(",", "_") attributes.setdefault("destination", promiser) model = self.create_attribute_object(promiser, attributes) @@ -92,7 +98,8 @@ def evaluate_promise(self, promiser: str, attributes: Dict, meta: Dict): result = Result.REPAIRED except subprocess.CalledProcessError as e: self.log_error("Failed clone: {error}".format(error=e.output or e)) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -133,7 +140,8 @@ def evaluate_promise(self, promiser: str, attributes: Dict, meta: Dict): result = Result.REPAIRED except subprocess.CalledProcessError as e: self.log_error("Failed reset: {error}".format(error=e.output or e)) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -160,7 +168,12 @@ def evaluate_promise(self, promiser: str, attributes: Dict, meta: Dict): # checkout the branch, if different from the current one output = self._git( model, - [model.executable, "rev-parse", "--abbrev-re", "HEAD".format()], + [ + model.executable, + "rev-parse", + "--abbrev-ref", + "HEAD".format(), + ], cwd=model.destination, ) detached = False @@ -192,6 +205,7 @@ def evaluate_promise(self, promiser: str, attributes: Dict, meta: Dict): [ model.executable, "diff", + "--shortstat", "..{remote}/{version}".format( remote=model.remote, version=model.version ), @@ -221,7 +235,8 @@ def evaluate_promise(self, promiser: str, attributes: Dict, meta: Dict): ) except subprocess.CalledProcessError as e: self.log_error("Failed fetch: {error}".format(error=e.output or e)) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -234,7 +249,9 @@ def evaluate_promise(self, promiser: str, attributes: Dict, meta: Dict): # everything okay return (result, classes) - def _git(self, model: object, args: List[str], cwd: Optional[str] = None) -> str: + def _git( + self, model: AttributeObject, args: List[str], cwd: Optional[str] = None + ) -> str: self.log_verbose("Run: {cmd}".format(cmd=" ".join(args))) output = ( subprocess.check_output( @@ -246,14 +263,18 @@ def _git(self, model: object, args: List[str], cwd: Optional[str] = None) -> str .strip() .decode("utf-8") ) - output != "" and self.log_verbose(output) + if output != "": + self.log_verbose(output) return output - def _git_envvars(self, model: object): + def _git_envvars(self, model: AttributeObject): env = os.environ.copy() env["GIT_SSH_COMMAND"] = model.ssh_executable if model.ssh_options: env["GIT_SSH_COMMAND"] += " " + model.ssh_options + if "HOME" not in env: + # git should have a HOME env var to retrieve .gitconfig, .git-credentials, etc + env["HOME"] = str(Path.home()) return env diff --git a/promise-types/git/test.cf b/promise-types/git/test.cf index 6fb482ac..513165fd 100644 --- a/promise-types/git/test.cf +++ b/promise-types/git/test.cf @@ -4,7 +4,6 @@ body common control } ####################################################### - bundle agent init { files: @@ -13,8 +12,7 @@ bundle agent init file_select => all, delete => init_delete; - "$(this.promise_dirname)/starter-pack/." - delete => init_delete; + "$(this.promise_dirname)/starter-pack/." delete => init_delete; } body depth_search aggressive @@ -35,15 +33,14 @@ body file_select all body delete init_delete { dirlinks => "delete"; - rmdirs => "true"; + rmdirs => "true"; } ####################################################### - promise agent git { - path => "$(this.promise_dirname)/git.py"; - interpreter => "/usr/bin/python3"; + path => "$(this.promise_dirname)/git.py"; + interpreter => "/usr/bin/python3"; } bundle agent test @@ -54,34 +51,33 @@ bundle agent test git: "starter_pack_repo" - repository => "https://github.com/cfengine/starter_pack", + repository => "https://github.com/cfengine/starter_pack", destination => "$(this.promise_dirname)/starter-pack", - version => "master"; + version => "master"; classes: - "promise_repaired" - expression => canonify("starter_pack_repo_updated"), - scope => "namespace"; + "promise_repaired" + expression => canonify("starter_pack_repo_updated"), + scope => "namespace"; } ####################################################### - bundle agent check { classes: - "file_ok" - if => fileexists("$(this.promise_dirname)/starter-pack/.git"); - - "ok" expression => "file_ok.promise_repaired"; + "file_ok" if => fileexists("$(this.promise_dirname)/starter-pack/.git"); + "ok" expression => "file_ok.promise_repaired"; reports: DEBUG.file_ok:: "file_ok"; + DEBUG.promise_repaired:: "promise_repaired"; ok:: "$(this.promise_filename) Pass"; + !ok:: "$(this.promise_filename) FAIL"; } diff --git a/promise-types/groups/README.md b/promise-types/groups/README.md index d82d0eb0..08754164 100644 --- a/promise-types/groups/README.md +++ b/promise-types/groups/README.md @@ -1,29 +1,24 @@ -# groups promise type +The `groups` promise type helps managing local groups, letting you ensure some users are a part of a group, or excluded from a group, etc. -## Synopsis - -* *Name*: `groups` -* *Version*: `0.1.3` -* *Description*: Manage local groups. -* *Note*: This is an experimental version of a promise type, and may be changed in the future. +**Note**: This is an experimental version of a promise type, and may be changed in the future. ## Requirements -* Unix-like system. +- Unix-like system. ## Attributes -| Name | Type | Description | Mandatory | Default | -| --------- | --------------------------- | ----------------------------------------------------------------------------------------------- | --------- | ------- | -| `policy` | `string` | Whether group should be present or absent on the local host | no | present | -| `members` | `string` / `data` / `body` | JSON string / data container / custom body containing attributes "include", "exclude" & "only" | no | - | -| `gid` | `integer` | The GID of the group | no | - | +| Name | Type | Description | Mandatory | Default | +| --------- | -------------------------- | ---------------------------------------------------------------------------------------------- | --------- | ------- | +| `policy` | `string` | Whether group should be present or absent on the local host | no | present | +| `members` | `string` / `data` / `body` | JSON string / data container / custom body containing attributes "include", "exclude" & "only" | no | - | +| `gid` | `integer` | The GID of the group | no | - | ## Examples Present group `foo` including user `alice` and `bob`, but excluding user `malcom`: -``` +```cf3 @if minimum_version(3.20) body members foo { @@ -48,7 +43,7 @@ bundle agent main Present group `bar` with GID `123` including only user `alice`: -``` +```cf3 @if minimum_version(3.20) body members bar { @@ -71,7 +66,7 @@ bundle agent main Absent group `baz`: -``` +```cf3 bundle agent main { groups: @@ -82,9 +77,8 @@ bundle agent main ## Authors -This software was created by the team at [Northern.tech AS](https://northern.tech), with many contributions from the community. Thanks everyone! - -[CFEngine](https://cfengine.com) is sponsored by [Northern.tech AS](https://northern.tech) +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! ## Contribute diff --git a/promise-types/groups/groups.cf b/promise-types/groups/groups.cf index f8c60ccd..e0ad4421 100644 --- a/promise-types/groups/groups.cf +++ b/promise-types/groups/groups.cf @@ -3,15 +3,12 @@ # Note: As of 3.20.0, custom promise modules support the use of custom bodies. # Versions before 3.20.0 uses JSON strings or data containers to define # members instead. - - promise agent groups # @brief Define groups promise type. { path => "$(sys.workdir)/modules/promises/groups.py"; interpreter => "/usr/bin/python3"; } - @if minimum_version(3.20) body members foo # @breif Define members for group foo using attributes `include` and @@ -25,13 +22,11 @@ bundle common foo # @breif Define members using JSON string. { vars: - "members" - string => '{ "include": ["alice", "bob"], + "members" + string => '{ "include": ["alice", "bob"], "exclude": ["malcom"] }'; } @endif - - @if minimum_version(3.20) body members bar # @breif Define members for group bar using attribute `only`. @@ -43,54 +38,44 @@ bundle common bar # @brief Define members using data container. { vars: - "members" - data => '{ "only": ["alice"] }'; + "members" data => '{ "only": ["alice"] }'; } @endif - - bundle agent example_groups # @breif Example groups promise statements. { groups: - "foo" - policy => "present", + "foo" + policy => "present", @if minimum_version(3.20) - members => foo; + members => foo; @else - members => "$(foo.members)"; + members => "$(foo.members)"; @endif - - "bar" + "bar" # policy defaults to present @if minimum_version(3.20) - members => bar, + members => bar, @else - members => "@(bar.members)", + members => "@(bar.members)", @endif - gid => "1234"; + gid => "1234"; - "baz" - policy => "absent"; + "baz" policy => "absent"; } - bundle agent example_users # @breif Create users for the groups example. { users: - "alice" - policy => "present"; - "bob" - policy => "present"; - "malcom" - policy => "present"; + "alice" policy => "present"; + "bob" policy => "present"; + "malcom" policy => "present"; } - bundle agent __main__ { methods: - "example_users"; - "example_groups"; + "example_users"; + "example_groups"; } diff --git a/promise-types/groups/groups.py b/promise-types/groups/groups.py index e11b6455..5b2b556d 100644 --- a/promise-types/groups/groups.py +++ b/promise-types/groups/groups.py @@ -1,16 +1,16 @@ import re import json from subprocess import Popen, PIPE -from cfengine import PromiseModule, ValidationError, Result +from cfengine_module_library import PromiseModule, ValidationError, Result class GroupsPromiseTypeModule(PromiseModule): def __init__(self): - super().__init__("groups_promise_module", "0.2.3") + super().__init__("groups_promise_module", "0.0.0") self._name_regex = re.compile(r"^[a-z_][a-z0-9_-]*[$]?$") self._name_maxlen = 32 - def validate_promise(self, promiser, attributes, meta): + def validate_promise(self, promiser, attributes, metadata): # check promiser value if self._name_regex.match(promiser) is None: self.log_warning( @@ -64,7 +64,7 @@ def validate_promise(self, promiser, attributes, meta): ) # check attribute gid value - if type(gid) == str: + if isinstance(gid, str): try: int(gid) except ValueError: @@ -115,7 +115,7 @@ def validate_promise(self, promiser, attributes, meta): % (duplicates, promiser) ) - def evaluate_promise(self, promiser, attributes, meta): + def evaluate_promise(self, promiser, attributes, metadata): # keep track of any repairs or failed repairs failed_repairs = 0 repairs = 0 diff --git a/promise-types/http/README.org b/promise-types/http/README.org index cbfbb553..8feab959 100644 --- a/promise-types/http/README.org +++ b/promise-types/http/README.org @@ -1,22 +1,24 @@ -* http promise type +The =http= promise type lets you perform HTTP(S) requests from policy. -** Synopsis +** Example -- /Name/: =http= -- /Version/: =1.0.0= -- /Description/: Perform HTTP(S) requests from policy. +Here is a simple example of downloading an SVG file (with a HTTP GET request): -** Promiser and attributes - -The promiser can either be a URL of the request or, if the /url/ attribute is -used (see below), it can be an arbitrary string. All attributes are optional if -the URL is specified in the promiser. +#+BEGIN_SRC cfengine3 +bundle agent __main__ +{ + http: + "https://cfengine.com/images/cfengine-logo.svg" + file => "/var/cfengine/cfengine-logo.svg", + if => not(fileexists("/var/cfengine/cfengine-logo.svg")); +} +#+END_SRC *** Attributes | Name | Type | Description | Mandatory | Default | |------------+-----------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------+-----------+------------| -| =url= | =string= | URL or the request (starting with "http://" or "https://") | No | /promiser/ | +| =url= | =string= | URL of the request (starting with "http://" or "https://") | No | /promiser/ | | =file= | =string= | File system path for where to save the response (body). | No | - | | =method= | =string= | HTTP method of the request (GET, POST, PUT, DELETE or PATCH) | No | - | | =headers= | =string=, =slist= or =data= | headers to send with the request (=data= with key-value pairs, =slist= with colon-separated key-value pairs, or =string= with key:value pairs) on separate lines | No | - | @@ -25,6 +27,10 @@ the URL is specified in the promiser. *Note:* if no =file= attribute is provided the request will be sent to =/dev/null=. This is useful for something like a REST API endpoint where all you care about is an OK response (http code between 200 and 300) that results in the promise being =KEPT= +*Note:* The promiser can either be a URL of the request or, if the /url/ attribute is +used, it can be an arbitrary string. All attributes are optional if +the URL is specified in the promiser. + ** Result classes :PROPERTIES: :CUSTOM_ID: result-classes @@ -45,25 +51,11 @@ defined in case the response code is not an OK code. *The promiser is canonified in all the above classes.* -** Examples - -#+BEGIN_SRC cfengine3 -bundle agent __main__ -{ - http: - "https://cfengine.com/images/cfengine-logo.svg" - file => "/var/cfengine/cfengine-logo.svg", - if => not(fileexists("/var/cfengine/cfengine-logo.svg")); -} -#+END_SRC - ** Authors -This software was created by the team at [[https://northern.tech][Northern.tech AS]], with many +This software was created by the team at [[https://northern.tech][Northern.tech]], with many contributions from the community. Thanks everyone! -[[https://cfengine.com][CFEngine]] is sponsored by [[https://northern.tech][Northern.tech AS]] - ** Contribute Feel free to open pull requests to expand this documentation, add features or diff --git a/promise-types/http/example.cf b/promise-types/http/example.cf index 1e76909d..3fd2050c 100644 --- a/promise-types/http/example.cf +++ b/promise-types/http/example.cf @@ -5,20 +5,23 @@ promise agent http interpreter => "/usr/bin/python3"; } -bundle agent backup_file(file) { +bundle agent backup_file(file) +{ http: - "http://localhost:8080/backups/$file" - method => "POST", - payload => "@$(file)", - if => not(canonify(concat("$(this.promiser)", "_POST_request_done"))); + "http://localhost:8080/backups/$file" + method => "POST", + payload => "@$(file)", + if => not(canonify(concat("$(this.promiser)", "_POST_request_done"))); } -bundle agent __main__ { +bundle agent __main__ +{ http: - "https://cfengine.com/images/cfengine-logo.svg" - file => "/var/cfengine/cfengine-logo.svg", - if => not(fileexists("/var/cfengine/cfengine-logo.svg")); + "https://cfengine.com/images/cfengine-logo.svg" + file => "/var/cfengine/cfengine-logo.svg", + if => not(fileexists("/var/cfengine/cfengine-logo.svg")); methods: - "Backup cf-lock.lmdb" usebundle => backup_file("/var/cfengine/state/cf_lock.lmdb"); + "Backup cf-lock.lmdb" + usebundle => backup_file("/var/cfengine/state/cf_lock.lmdb"); } diff --git a/promise-types/http/http_promise_type.py b/promise-types/http/http_promise_type.py index 3f2cdea8..aaa7b852 100644 --- a/promise-types/http/http_promise_type.py +++ b/promise-types/http/http_promise_type.py @@ -2,17 +2,17 @@ import filecmp import os -import urllib +import urllib.error import urllib.request import ssl import json from contextlib import contextmanager -from cfengine import PromiseModule, ValidationError, Result - +from cfengine_module_library import PromiseModule, ValidationError, Result _SUPPORTED_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH"} + class FileInfo: def __init__(self, target): self.target = target @@ -20,68 +20,89 @@ def __init__(self, target): class HTTPPromiseModule(PromiseModule): - def __init__(self, name="http_promise_module", version="2.0.0", **kwargs): + def __init__(self, name="http_promise_module", version="0.0.0", **kwargs): super().__init__(name, version, **kwargs) - def validate_promise(self, promiser, attributes, meta): + def validate_promise(self, promiser, attributes, metadata): if "url" in attributes: url = attributes["url"] - if type(url) != str: + if not isinstance(url, str): raise ValidationError("'url' must be a string") if not url.startswith(("https://", "http://")): raise ValidationError("Only HTTP(S) requests are supported") if "method" in attributes: method = attributes["method"] - if type(method) != str: + if not isinstance(method, str): raise ValidationError("'method' must be a string") if method not in _SUPPORTED_METHODS: - raise ValidationError("'method' must be one of %s" % ", ".join(_SUPPORTED_METHODS)) + raise ValidationError( + "'method' must be one of %s" % ", ".join(_SUPPORTED_METHODS) + ) if "headers" in attributes: headers = attributes["headers"] headers_type = type(headers) - if headers_type == str: + if headers_type is str: headers_lines = headers.splitlines() if any(line.count(":") != 1 for line in headers_lines): - raise ValidationError("'headers' must be string with 'name: value' pairs on separate lines") - elif headers_type == list: + raise ValidationError( + "'headers' must be string with 'name: value' pairs on separate lines" + ) + elif headers_type is list: if any(line.count(":") != 1 for line in headers): - raise ValidationError("'headers' must be a list of 'name: value' pairs") - elif headers_type == dict: + raise ValidationError( + "'headers' must be a list of 'name: value' pairs" + ) + elif headers_type is dict: # nothing to check for dict? pass else: - raise ValidationError("'headers' must be a string, an slist or a data container" + - " value with 'name: value' pairs") + raise ValidationError( + "'headers' must be a string, an slist or a data container" + + " value with 'name: value' pairs" + ) if "payload" in attributes: payload = attributes["payload"] if type(payload) not in (str, dict): - raise ValidationError("'payload' must be a string or a data container value") + raise ValidationError( + "'payload' must be a string or a data container value" + ) - if type(payload) == str and payload.startswith("@") and not os.path.isabs(payload[1:]): + if ( + isinstance(payload, str) + and payload.startswith("@") + and not os.path.isabs(payload[1:]) + ): raise ValidationError("File-based payload must be an absolute path") if "file" in attributes: file_ = attributes["file"] - if type(file_) != str or not os.path.isabs(file_): + if not isinstance(file_, str) or not os.path.isabs(file_): raise ValidationError("'file' must be an absolute path to a file") if "insecure" in attributes: insecure = attributes["insecure"] - if type(insecure) != str or insecure not in ("true", "True", "false", "False"): - raise ValidationError("'insecure' must be either \"true\" or \"false\"") + if not isinstance(insecure, str) or insecure not in ( + "true", + "True", + "false", + "False", + ): + raise ValidationError('\'insecure\' must be either "true" or "false"') @contextmanager def target_fh(self, file_info): if file_info.target: dirname = os.path.dirname(file_info.target) os.makedirs(dirname, exist_ok=True) - temp_file = file_info.target+".cftemp" + temp_file = file_info.target + ".cftemp" with open(temp_file, "wb") as fh: yield fh - if not os.path.isfile(file_info.target) or not filecmp.cmp(temp_file, file_info.target): + if not os.path.isfile(file_info.target) or not filecmp.cmp( + temp_file, file_info.target + ): os.replace(temp_file, file_info.target) file_info.was_repaired = True else: @@ -90,8 +111,7 @@ def target_fh(self, file_info): # this is to do something like API requests where you don't care about the result other than response code yield open(os.devnull, "wb") - - def evaluate_promise(self, promiser, attributes, meta): + def evaluate_promise(self, promiser, attributes, metadata): url = attributes.get("url", promiser) method = attributes.get("method", "GET") headers = attributes.get("headers", dict()) @@ -100,24 +120,39 @@ def evaluate_promise(self, promiser, attributes, meta): insecure = attributes.get("insecure", False) result = Result.KEPT - canonical_promiser = promiser.translate(str.maketrans({char: "_" for char in ("@", "/", ":", "?", "&", "%")})) + canonical_promiser = promiser.translate( + str.maketrans({char: "_" for char in ("@", "/", ":", "?", "&", "%")}) + ) - if headers and type(headers) != dict: - if type(headers) == str: - headers = {key: value for key, value in (line.split(":") for line in headers.splitlines())} - elif type(headers) == list: - headers = {key: value for key, value in (line.split(":") for line in headers)} + if headers and not isinstance(headers, dict): + if isinstance(headers, str): + headers = { + key: value + for key, value in (line.split(":") for line in headers.splitlines()) + } + elif isinstance(headers, list): + headers = { + key: value for key, value in (line.split(":") for line in headers) + } if payload: - if type(payload) == dict: + if isinstance(payload, dict): try: payload = json.dumps(payload) except TypeError: - self.log_error("Failed to convert 'payload' to text representation for request '%s'" % url) - return (Result.NOT_KEPT, - ["%s_%s_request_failed" % (canonical_promiser, method), - "%s_%s_payload_failed" % (canonical_promiser, method), - "%s_%s_payload_conversion_failed" % (canonical_promiser, method)]) + self.log_error( + "Failed to convert 'payload' to text representation for request '%s'" + % url + ) + return ( + Result.NOT_KEPT, + [ + "%s_%s_request_failed" % (canonical_promiser, method), + "%s_%s_payload_failed" % (canonical_promiser, method), + "%s_%s_payload_conversion_failed" + % (canonical_promiser, method), + ], + ) if "Content-Type" not in headers: headers["Content-Type"] = "application/json" @@ -129,34 +164,49 @@ def evaluate_promise(self, promiser, attributes, meta): # scope. Thank you, Python! payload = open(path, "rb") except OSError as e: - self.log_error("Failed to open payload file '%s' for request '%s': %s" % (path, url, e)) - return (Result.NOT_KEPT, - ["%s_%s_request_failed" % (canonical_promiser, method), - "%s_%s_payload_failed" % (canonical_promiser, method), - "%s_%s_payload_file_failed" % (canonical_promiser, method)]) + self.log_error( + "Failed to open payload file '%s' for request '%s': %s" + % (path, url, e) + ) + return ( + Result.NOT_KEPT, + [ + "%s_%s_request_failed" % (canonical_promiser, method), + "%s_%s_payload_failed" % (canonical_promiser, method), + "%s_%s_payload_file_failed" % (canonical_promiser, method), + ], + ) if "Content-Length" not in headers: - headers["Content-Length"] = os.path.getsize(path) + headers["Content-Length"] = str(os.path.getsize(path)) # must be 'None' or bytes or file object - if type(payload) == str: + if isinstance(payload, str): payload = payload.encode("utf-8") - request = urllib.request.Request(url=url, data=payload, method=method, headers=headers) + request = urllib.request.Request( + url=url, data=payload, method=method, headers=headers + ) SSL_context = None if insecure: # convert to a boolean - insecure = (insecure.lower() == "true") + insecure = insecure.lower() == "true" if insecure: - SSL_context = ssl.SSLContext() - SSL_context.verify_method = ssl.CERT_NONE + SSL_context = ssl.create_default_context() + SSL_context.check_hostname = False + SSL_context.verify_mode = ssl.CERT_NONE try: with urllib.request.urlopen(request, context=SSL_context) as url_req: if not (200 <= url_req.status < 300): - self.log_error("Request for '%s' failed with code %d" % (url, url_req.status)) - return (Result.NOT_KEPT, ["%s_%s_request_failed" % (canonical_promiser, method)]) + self.log_error( + "Request for '%s' failed with code %d" % (url, url_req.status) + ) + return ( + Result.NOT_KEPT, + ["%s_%s_request_failed" % (canonical_promiser, method)], + ) # TODO: log progress when url_req.headers["Content-length"] > REPORTING_THRESHOLD file_info = FileInfo(target) with self.target_fh(file_info) as target_file: @@ -169,22 +219,38 @@ def evaluate_promise(self, promiser, attributes, meta): result = Result.REPAIRED except urllib.error.URLError as e: self.log_error("Failed to request '%s': %s" % (url, e)) - return (Result.NOT_KEPT, ["%s_%s_request_failed" % (canonical_promiser, method)]) + return ( + Result.NOT_KEPT, + ["%s_%s_request_failed" % (canonical_promiser, method)], + ) except OSError as e: - self.log_error("Failed to store '%s' response to '%s': %s" % (url, target, e)) - return (Result.NOT_KEPT, - ["%s_%s_request_failed" % (canonical_promiser, method), - "%s_%s_file_failed" % (canonical_promiser, method)]) + self.log_error( + "Failed to store '%s' response to '%s': %s" % (url, target, e) + ) + return ( + Result.NOT_KEPT, + [ + "%s_%s_request_failed" % (canonical_promiser, method), + "%s_%s_file_failed" % (canonical_promiser, method), + ], + ) if target: if result == Result.REPAIRED: - self.log_info("Saved request response from '%s' to '%s'" % (url, target)) + self.log_info( + "Saved request response from '%s' to '%s'" % (url, target) + ) else: - self.log_info("No changes in request response from '%s' to '%s'" % (url, target)) + self.log_info( + "No changes in request response from '%s' to '%s'" % (url, target) + ) else: - self.log_info("Successfully executed%s request to '%s'" % ((" " + method if method else ""), - url)) + self.log_info( + "Successfully executed%s request to '%s'" + % ((" " + method if method else ""), url) + ) return (result, ["%s_%s_request_done" % (canonical_promiser, method)]) + if __name__ == "__main__": HTTPPromiseModule().start() diff --git a/promise-types/iptables/README.md b/promise-types/iptables/README.md index d1493408..5c916c1a 100644 --- a/promise-types/iptables/README.md +++ b/promise-types/iptables/README.md @@ -1,9 +1,4 @@ -# Iptables Promise Module - -## Synopsis -- *Name*: `iptables` -- *Version*: `0.1.2` -- *Description*: Manage network packet filter rules +Promise type for managing `iptables` firewall rules. ## Requirements - [`iptables`](https://manpages.ubuntu.com/manpages/precise/en/man8/iptables.8.html) (command line tool) @@ -221,9 +216,8 @@ All rules added by the iptables custom promise will have a comment signifing it ## Authors -This software was created by the team at [Northern.tech AS](https://northern.tech), with many contributions from the community. Thanks everyone! - -[CFEngine](https://cfengine.com) is sponsored by [Northern.tech AS](https://northern.tech) +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! ## Contribute diff --git a/promise-types/iptables/iptables.py b/promise-types/iptables/iptables.py index aab84c31..ba2d86bb 100644 --- a/promise-types/iptables/iptables.py +++ b/promise-types/iptables/iptables.py @@ -3,7 +3,12 @@ as they are in `man iptables` """ -from cfengine import PromiseModule, ValidationError, Result, AttributeObject +from cfengine_module_library import ( + PromiseModule, + ValidationError, + Result, + AttributeObject, +) from typing import Callable, List, Dict, Tuple from collections import namedtuple from itertools import takewhile, dropwhile @@ -117,7 +122,7 @@ class IptablesPromiseTypeModule(PromiseModule): } def __init__(self, **kwargs): - super().__init__("iptables_promise_module", "0.2.2", **kwargs) + super().__init__("iptables_promise_module", "0.0.0", **kwargs) def must_be_one_of(items) -> Callable: def validator(v): @@ -159,7 +164,7 @@ def must_be_non_negative(v): self.add_attribute("rules", dict) self.add_attribute("executable", str, default="iptables") - def validate_promise(self, promiser: str, attributes: dict, meta: dict): + def validate_promise(self, promiser: str, attributes: dict, metadata: dict): command = attributes["command"] denied_attrs = self._collect_denied_attributes_of_command(command, attributes) @@ -182,7 +187,7 @@ def validate_promise(self, promiser: str, attributes: dict, meta: dict): if command != "flush" and attributes.get("chain") == "ALL": raise ValidationError("Chain 'ALL' is only available for command 'flush'") - def evaluate_promise(self, promiser: str, attributes: dict, meta: dict): + def evaluate_promise(self, promiser: str, attributes: Dict, metadata: Dict): safe_promiser = promiser.replace(",", "_") model = Model( @@ -217,7 +222,8 @@ def evaluate_promise(self, promiser: str, attributes: dict, meta: dict): if result == Result.NOT_KEPT: classes.append("{}_{}_failed".format(safe_promiser, command)) elif result in {Result.KEPT, Result.REPAIRED}: - result == Result.REPAIRED and self.log_info(model.log_str) + if result == Result.REPAIRED: + self.log_info(model.log_str) classes.append("{}_{}_successful".format(safe_promiser, command)) else: @@ -225,7 +231,7 @@ def evaluate_promise(self, promiser: str, attributes: dict, meta: dict): return result, classes - def evaluate_command_policy(self, executable, table, chain, target) -> Result: + def evaluate_command_policy(self, executable, table, chain, target) -> str: policy_rules = self._iptables_policy_rules_of(executable, table, chain) assert len(policy_rules) == 1 and len(policy_rules[0].split()) >= 1 @@ -281,7 +287,7 @@ def _iptables_policy(self, executable, table, chain, target): def _iptables_flush(self, executable, table, chain): args = [executable, "-t", table, "-F"] - if chain != 'ALL': + if chain != "ALL": args.append(chain) self._run(args) @@ -290,7 +296,7 @@ def _iptables_all_rules_of(self, executable, table, chain) -> List[str]: If chain is specified then only _one_ policy rule will be on top. """ args = [executable, "-t", table, "-S"] - if chain != 'ALL': + if chain != "ALL": args.append(chain) return self._run(args) diff --git a/promise-types/iptables/test.cf b/promise-types/iptables/test.cf index 3a7104f7..78414101 100755 --- a/promise-types/iptables/test.cf +++ b/promise-types/iptables/test.cf @@ -1,49 +1,44 @@ promise agent iptables { - path => "$(sys.workdir)/modules/promises/iptables.py"; - interpreter => "/usr/bin/python3"; + path => "$(sys.workdir)/modules/promises/iptables.py"; + interpreter => "/usr/bin/python3"; } body common control { - bundlesequence => { - aggressive_policy, - input_flushed, - all_flushed, - }; + bundlesequence => { aggressive_policy, input_flushed, all_flushed }; } bundle agent aggressive_policy { iptables: - "aggressive_policy" - command => "policy", - chain => "INPUT", - target => "DROP"; + "aggressive_policy" + command => "policy", + chain => "INPUT", + target => "DROP"; reports: - "--- ${this.bundle} ---"; + "--- ${this.bundle} ---"; } bundle agent input_flushed { iptables: - "input_flushed" - command => "flush", - chain => "INPUT"; + "input_flushed" + command => "flush", + chain => "INPUT"; reports: - "--- ${this.bundle} ---"; + "--- ${this.bundle} ---"; } bundle agent all_flushed { iptables: - "all_flushed" - command => "flush", - chain => "ALL"; + "all_flushed" + command => "flush", + chain => "ALL"; reports: - "--- ${this.bundle} ---"; + "--- ${this.bundle} ---"; } - diff --git a/promise-types/json/README.md b/promise-types/json/README.md new file mode 100644 index 00000000..035e298f --- /dev/null +++ b/promise-types/json/README.md @@ -0,0 +1,155 @@ +Promise type for manipulating `json` files + +## Attributes + +| Name | Type | Description | +| ----------- | ---------------- | ----------------------------------------------------------------------------- | +| `object` | `data container` | json object type. It can also be json arrays | +| `array` | `data array` | json array type | +| `string` | `string` | json string type | +| `number` | `real`, `int` | json number type | +| `primitive` | `string` | Primitives are values that are either `"true"`, `"false"` or `"null"` in json | + +## Examples + +### Write to a whole file + +To write to a json file, you can do: + +```cfengine3 +bundle agent main +{ + json: + "/tmp/newfile.json" + array => '["hello", "world"]'; +} +``` + +The resulting `/tmp/newfile.json` will only contain the array: + +```json +["hello", "world"] +``` + +If the `/tmp/newfile.json` doesn't exist, it will be created. If it exists and contains some data, they will be overwritten. + +### Write to a specific field + +Given a json file `/tmp/oldfile.json`, + +```json +{ + "foo": "bar" +} +``` + +we can modify/append a field by doing: + +```cfengine3 +bundle agent main +{ + json: + "/tmp/oldfile.json:greeting" + array => '["hello", "world"]'; +} +``` + +And the content of `/tmp/oldfile.json` will become: + +```json +{ + "foo": "bar", + "greeting": ["hello", "world"] +} +``` + +If the field doesn't exist, it is appended. If it already exists, its data will be overwritten. + +### Writing arrays + +In order to write compound type such as arrays containg booleans, numbers, etc... One has to use the `data` type in the policy. + +To see what happens if we use + +```cfengine3 +bundle agent main +{ + vars: + "json_data" + data => '[1.2, true, "hello!"]'; + + "real_list" + rlist => {"1.2", "2.3"}; + "bool_list" + slist => {"true", "false"}; + + json: + "/tmp/example_1.json:json_data" + array => "@(json_data)"; + + "/tmp/example_2.json:real_list" + array => "@(real_list)"; + "/tmp/example_2.json:bool_list" + array => "@(bool_list)"; +} +``` + +We can compare the content of `/tmp/example_1.json` and `/tmp/example_2.json`: + +```json +{ + "json_data": [1.2, true, "hello!"] +} +``` + +```json +{ + "real_list": ["1.2", "2.3"], + "bool_list": ["true", "false"] +} +``` + +As we can see, using slist, rlist or ilist to write arrays will always result in array of strings. If we want more complex arrays using containg number, true, false or null, then we need to use the `data container` type. + +## Not implemented yet + +The copy attribute allows to copy the content of a json file into another json file. For example, `/tmp/oldfile.json` contains the following: + +```json +{ + "hello": "world" +} +``` + +We can copy it into the `/tmp/newfile.json` in the field `"oldfile"` by doing: + +```cfengine3 +bundle agent main +{ + json: + "/tmp/newfile.json:oldfile" + copy => "/tmp/oldfile.json"; +} +``` + +```json +{ + "oldfile": { + "hello": "world" + } +} +``` + +## Authors + +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! + +## Contribute + +Feel free to open pull requests to expand this documentation, add features, or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://northerntech.atlassian.net/). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/promise-types/json/enable.cf b/promise-types/json/enable.cf new file mode 100644 index 00000000..cccfe944 --- /dev/null +++ b/promise-types/json/enable.cf @@ -0,0 +1,6 @@ +promise agent json +# @brief Define json promise type +{ + path => "$(sys.workdir)/modules/promises/json_promise_type.py"; + interpreter => "/usr/bin/python3"; +} diff --git a/promise-types/json/example.cf b/promise-types/json/example.cf new file mode 100644 index 00000000..12a52302 --- /dev/null +++ b/promise-types/json/example.cf @@ -0,0 +1,12 @@ +promise agent json +# @brief Define json promise type +{ + path => "$(sys.workdir)/modules/promises/json_promise_type.py"; + interpreter => "/usr/bin/python3"; +} + +bundle agent main +{ + json: + "/tmp/myusers.json:name" string => "John"; +} diff --git a/promise-types/json/json_promise_type.py b/promise-types/json/json_promise_type.py new file mode 100644 index 00000000..4048b9be --- /dev/null +++ b/promise-types/json/json_promise_type.py @@ -0,0 +1,183 @@ +import os +import json +import tempfile +import shutil + +from cfengine_module_library import ( + PromiseModule, + ValidationError, + Result, + AttributeObject, +) + + +def is_number(num): + try: + float(num) + return True + except ValueError: + return False + + +def is_json_serializable(string): + try: + json.loads(string) + return True + except json.JSONDecodeError: + return False + + +class JsonPromiseTypeModule(PromiseModule): + + def __init__(self, **kwargs): + super(JsonPromiseTypeModule, self).__init__( + name="json_promise_module", version="0.0.0", **kwargs + ) + + self.types = ["object", "array", "string", "number", "primitive"] + self.valid_attributes = ( + self.types + ) # for now, the only valid attributes are the types. + + def create_attribute_object(self, promiser, attributes): + data = {t: None for t in self.valid_attributes} + for attr, val in attributes.items(): + data[attr] = val + return AttributeObject(data) + + def validate_promise(self, promiser, attributes, metadata): + + for attr in attributes: + if attr not in self.valid_attributes: + raise ValidationError("Unknown attribute '{}'".format(attr)) + + present_types = [t for t in self.types if t in attributes] + if present_types == 0: + raise ValidationError( + "The promiser '{}' is missing a type attribute. The possible types are {}".format( + promiser, ", ".join(["'{}'".format(t) for t in self.types]) + ) + ) + elif len(present_types) > 1: + raise ValidationError( + "The attributes {} cannot be together".format( + ", ".join(["'{}'".format(t) for t in self.types]) + ) + ) + + filename, colon, field = promiser.partition(":") + + if not filename: + raise ValidationError("Invalid syntax: missing file name") + + if colon and not field: + raise ValidationError("Invalid syntax: field specified but empty") + + model = self.create_attribute_object(promiser, attributes) + if ( + model.object + and isinstance(model.object, str) + and not is_json_serializable(model.object) + ): + raise ValidationError( + "'{}' is not a valid data container".format(model.object) + ) + + if model.array: + if isinstance(model.array, str): + try: + array = json.loads(model.array) + + except: + raise ValidationError( + "'{}' cannot be serialized to a json array".format(model.array) + ) + if not isinstance(array, list): + raise ValidationError( + "'{}' is not a valid data array".format(model.array) + ) + + elif not isinstance(model.array, list): + raise ValidationError( + "'{}' is not a valid data array".format(model.array) + ) + + if model.number and not is_number(model.number): + raise ValidationError( + "'{}' is not a valid int or real".format(model.number) + ) + + if model.primitive and model.primitive not in ["true", "false", "null"]: + raise ValidationError( + "expected 'true', 'false' or 'null' but got '{}".format(model.primitive) + ) + + def evaluate_promise(self, promiser, attributes, metadata): + model = self.create_attribute_object(promiser, attributes) + filename, _, field = promiser.partition(":") + + if os.path.exists(filename) and not os.path.isfile(filename): + self.log_error( + "'{}' already exists and is not a regular file".format(filename) + ) + return Result.NOT_KEPT + + # type conversion + + datatype = next(t for t in self.types if t in attributes) + + if isinstance(attributes[datatype], str) and not model.string: + data = json.loads(attributes[datatype]) + else: + data = attributes[datatype] + + # json manipulation + + try: + with open(filename, "r+") as f: + content = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + content = {} + except Exception as e: + self.log_error("Failed to read '{}': {}".format(filename, e)) + return Result.NOT_KEPT + + if field: + if not isinstance(content, dict): + content = {} + self.log_warning( + "Tried to access '{}' in '{}' when the content is not subscriptable. Overwriting the file...".format( + field, filename + ) + ) + + if field in content and content[field] == data: + self.log_info("'{}' is already up to date".format(promiser)) + return Result.KEPT + content[field] = data + else: + if content == data: + self.log_info("'{}' is already up to date".format(promiser)) + return Result.KEPT + content = data + + fd, tmp = tempfile.mkstemp() + json_bytes = json.dumps(content, indent=4).encode("utf-8") + written = os.write(fd, json_bytes) + os.close(fd) + shutil.move(tmp, filename) + + if written != len(json_bytes): + self.log_error( + "Couldn't write all the data to the file '{}'. Wrote {} out of {} bytes".format( + filename, written, len(json_bytes) + ) + ) + return Result.NOT_KEPT + + self.log_info("Updated '{}'".format(filename)) + return Result.REPAIRED + + +if __name__ == "__main__": + JsonPromiseTypeModule().start() diff --git a/promise-types/json/test.cf b/promise-types/json/test.cf new file mode 100644 index 00000000..c7b00901 --- /dev/null +++ b/promise-types/json/test.cf @@ -0,0 +1,161 @@ +body common control +{ + inputs => { "$(sys.libdir)/stdlib.cf" }; + version => "1.0"; + bundlesequence => { "init", "test", "check", "cleanup" }; +} + +####################################################### +bundle agent init +{ + vars: + "to_overwrite" + data => readjson( + "$(this.promise_dirname)/tests/to_overwrite.start.json", 100k + ); + + "to_modify" + data => readjson( + "$(this.promise_dirname)/tests/to_modify.start.json", 100k + ); + + files: + "$(this.promise_dirname)/tests/to_overwrite.json" + create => "true", + content => "$(to_overwrite)"; + + "$(this.promise_dirname)/tests/to_modify.json" + create => "true", + content => "$(to_modify)"; + + "$(this.promise_dirname)/tests/to_append_1.json" create => "true"; +} + +####################################################### +promise agent json +{ + path => "$(this.promise_dirname)/json_promise_type.py"; + interpreter => "/usr/bin/python3"; +} + +bundle agent test +{ + vars: + "objects" data => '{ "bar": [1, 2, 3] }'; + "int_arrays" data => '[1,2,3]'; + "arrays" ilist => { "1", "2" }; + + # slist == rlist == ilist -> lists of string + "numbers" int => "1"; + "strings" string => "hello"; + "bools" string => "true"; + "nulls" string => "null"; + + json: + "$(this.promise_dirname)/tests/to_overwrite.json" object => "@(objects)"; + "$(this.promise_dirname)/tests/to_modify.json:Hello" string => "$(strings)"; + "$(this.promise_dirname)/tests/to_append_1.json:a" object => "@(objects)"; + + "$(this.promise_dirname)/tests/to_append_1.json:b" + object => "@(int_arrays)"; + + "$(this.promise_dirname)/tests/to_append_1.json:c" array => "@(int_arrays)"; + "$(this.promise_dirname)/tests/to_append_1.json:d" string => "$(strings)"; + "$(this.promise_dirname)/tests/to_append_1.json:e" number => "$(numbers)"; + "$(this.promise_dirname)/tests/to_append_1.json:f" primitive => "$(bools)"; + "$(this.promise_dirname)/tests/to_append_1.json:g" primitive => "$(nulls)"; + + "$(this.promise_dirname)/tests/to_append_2.json:a" + object => '{ "bar": [1, 2, 3] }'; + + "$(this.promise_dirname)/tests/to_append_2.json:b" object => '[1,2,3]'; + "$(this.promise_dirname)/tests/to_append_2.json:c" array => '[1,2,3]'; + "$(this.promise_dirname)/tests/to_append_2.json:d" string => "hello"; + "$(this.promise_dirname)/tests/to_append_2.json:e" number => "1"; + "$(this.promise_dirname)/tests/to_append_2.json:f" primitive => "true"; + "$(this.promise_dirname)/tests/to_append_2.json:g" primitive => "null"; +} + +####################################################### +bundle agent check +{ + vars: + "to_overwrite_content" + data => readjson("$(this.promise_dirname)/tests/to_overwrite.json", 100k); + + "to_modify_content" + data => readjson("$(this.promise_dirname)/tests/to_modify.json", 100k); + + "to_append_1_content" + data => readjson("$(this.promise_dirname)/tests/to_append_1.json", 100k); + + "to_append_2_content" + data => readjson("$(this.promise_dirname)/tests/to_append_2.json", 100k); + + "to_overwrite_content_true" + data => readjson( + "$(this.promise_dirname)/tests/to_overwrite.expected.json", 100k + ); + + "to_modify_content_true" + data => readjson( + "$(this.promise_dirname)/tests/to_modify.expected.json", 100k + ); + + "to_append_content_true" + data => readjson( + "$(this.promise_dirname)/tests/to_append.expected.json", 100k + ); + + "to_overwrite_content_indices" slist => getindices("to_overwrite_content"); + "to_modify_content_indices" slist => getindices("to_modify_content"); + "to_append_1_content_indices" slist => getindices("to_append_1_content"); + "to_append_2_content_indices" slist => getindices("to_append_2_content"); + + "to_overwrite_content_true_indices" + slist => getindices("to_overwrite_content_true"); + + "to_modify_content_true_indices" + slist => getindices("to_modify_content_true"); + + "to_append_content_true_indices" + slist => getindices("to_append_content_true"); + + classes: + "ok" + expression => and( + strcmp( + "$(to_overwrite_content[$(to_overwrite_content_indices)])", + "$(to_overwrite_content_true[$(to_overwrite_content_true_indices)])" + ), + strcmp( + "$(to_modify_content[$(to_modify_content_indices)])", + "$(to_modify_content_true[$(to_modify_content_true_indices)])" + ), + strcmp( + "$(to_append_1_content[$(to_append_1_content_indices)])", + "$(to_append_2_content[$(to_append_2_content_indices)])" + ), + strcmp( + "$(to_append_1_content[$(to_append_1_content_indices)])", + "$(to_append_content_true[$(to_append_content_true_indices)])" + ) + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; +} + +# ####################################################### +bundle agent cleanup +{ + files: + "$(this.promise_dirname)/tests/to_overwrite.json" delete => tidy; + "$(this.promise_dirname)/tests/to_modify.json" delete => tidy; + "$(this.promise_dirname)/tests/to_append_1.json" delete => tidy; + "$(this.promise_dirname)/tests/to_append_2.json" delete => tidy; +} diff --git a/promise-types/json/tests/to_append.expected.json b/promise-types/json/tests/to_append.expected.json new file mode 100644 index 00000000..8c84adc3 --- /dev/null +++ b/promise-types/json/tests/to_append.expected.json @@ -0,0 +1,22 @@ +{ + "a": { + "bar": [ + 1, + 2, + 3 + ] + }, + "b": [ + 1, + 2, + 3 + ], + "c": [ + "1", + "2" + ], + "d": "hello", + "e": 1, + "f": true, + "g": null +} diff --git a/promise-types/json/tests/to_modify.expected.json b/promise-types/json/tests/to_modify.expected.json new file mode 100644 index 00000000..1ded6095 --- /dev/null +++ b/promise-types/json/tests/to_modify.expected.json @@ -0,0 +1,3 @@ +{ + "Hello": "hello" +} diff --git a/promise-types/json/tests/to_modify.start.json b/promise-types/json/tests/to_modify.start.json new file mode 100644 index 00000000..c7acc90c --- /dev/null +++ b/promise-types/json/tests/to_modify.start.json @@ -0,0 +1,3 @@ +{ + "Hello": "World" +} diff --git a/promise-types/json/tests/to_overwrite.expected.json b/promise-types/json/tests/to_overwrite.expected.json new file mode 100644 index 00000000..7117d3f7 --- /dev/null +++ b/promise-types/json/tests/to_overwrite.expected.json @@ -0,0 +1,7 @@ +{ + "bar": [ + 1, + 2, + 3 + ] +} diff --git a/promise-types/json/tests/to_overwrite.start.json b/promise-types/json/tests/to_overwrite.start.json new file mode 100644 index 00000000..c7acc90c --- /dev/null +++ b/promise-types/json/tests/to_overwrite.start.json @@ -0,0 +1,3 @@ +{ + "Hello": "World" +} diff --git a/promise-types/sshd/LICENSE b/promise-types/sshd/LICENSE new file mode 100644 index 00000000..eb2ada6d --- /dev/null +++ b/promise-types/sshd/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Northern.tech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/promise-types/sshd/README.md b/promise-types/sshd/README.md new file mode 100644 index 00000000..7073fa3f --- /dev/null +++ b/promise-types/sshd/README.md @@ -0,0 +1,67 @@ +Configures sshd and restarts the service when configuration changes. + +## Promiser + +The sshd configuration keyword to manage (e.g. `PermitRootLogin`, `AllowUsers`). +Each promise manages a single directive in the drop-in config file. + +## Attributes + +- `value` (required) — the value for the directive, either a string or an slist + +## What the module manages internally + +1. **Include directive** — ensures the base `sshd_config` includes the drop-in directory (`sshd_config.d/`) as its first non-comment directive +2. **Drop-in directory** — creates the drop-in directory if it doesn't exist +3. **Drop-in file** — writes directives to `sshd_config.d/00-cfengine.conf` +4. **Service restart** — restarts sshd if configuration was changed and the service is already running +5. **Verification** — verifies the desired directive appears in the effective sshd config (`sshd -T`) + +## Conflicting promisers + +Having multiple promises with the same sshd keyword is not recommended. +In case of conflicting promisers, the agent will attempt to converge the correct state for each one in the order they are evaluated. +This means the last promise wins and determines the final value in the configuration file. +It will also cause multiple restarts of the sshd service, which may be disruptive. + +## What the module does NOT do + +- Install sshd — that is a `packages:` promise +- Ensure sshd is running — that is a `services:` promise +- Manage match blocks — those are a policy-level concern + +## Policy + +```cf3 +bundle agent sshd_config +{ + packages: + "openssh-server" policy => "present"; + + services: + "sshd" service_policy => "start"; + + vars: + "allowed_users" slist => { "alice", "bob" }; + + sshd: + "PermitRootLogin" value => "no"; + "PasswordAuthentication" value => "no"; + "Port" value => "22"; + "AllowUsers" value => @(allowed_users); +} +``` + +## Authors + +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! + +## Contribute + +Feel free to open pull requests to expand this documentation, add features, or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://northerntech.atlassian.net/). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/promise-types/sshd/enable.cf b/promise-types/sshd/enable.cf new file mode 100644 index 00000000..bc3ea19f --- /dev/null +++ b/promise-types/sshd/enable.cf @@ -0,0 +1,6 @@ +promise agent sshd +# @brief Define sshd promise type +{ + path => "$(sys.workdir)/modules/promises/sshd_promise_type.py"; + interpreter => "/usr/bin/python3"; +} diff --git a/promise-types/sshd/example.cf b/promise-types/sshd/example.cf new file mode 100644 index 00000000..3e83b4bf --- /dev/null +++ b/promise-types/sshd/example.cf @@ -0,0 +1,30 @@ +promise agent sshd +# @brief Define sshd promise type +{ + path => "$(sys.workdir)/modules/promises/sshd_promise_type.py"; + interpreter => "/usr/bin/python3"; +} + +bundle agent example +{ + packages: + "openssh-server" policy => "present"; + + services: + "sshd" service_policy => "start"; + + vars: + "allowed_users" slist => { "alice", "bob" }; + + sshd: + "PermitRootLogin" value => "no"; + "PasswordAuthentication" value => "no"; + "Port" value => "22"; + "AllowUsers" value => @(allowed_users); +} + +bundle agent __main__ +{ + methods: + "example"; +} diff --git a/promise-types/sshd/sshd_promise_type.py b/promise-types/sshd/sshd_promise_type.py new file mode 100644 index 00000000..44aa77cf --- /dev/null +++ b/promise-types/sshd/sshd_promise_type.py @@ -0,0 +1,318 @@ +import os +import re +import subprocess +import tempfile + +from cfengine_module_library import PromiseModule, Result, ValidationError + +BASE_CONFIG = "/etc/ssh/sshd_config" +DROP_IN_DIR = "/etc/ssh/sshd_config.d/" +CFE_CONFIG = os.path.join(DROP_IN_DIR, "00-cfengine.conf") + + +# TODO: Add a "restart" attribute (default: True) to allow overriding the +# automatic restart of sshd after configuration changes. +# TODO: Add a "start" attribute (default: False) to optionally start the sshd +# service if it is not already running. +# TODO: Append the policy comment (e.g. "# Promised by CFEngine") as a trailing +# comment on each directive written to the drop-in file. + +REQUIRED_ATTRIBUTES = ("value",) # Add required attributes here +ACCEPTED_ATTRIBUTES = REQUIRED_ATTRIBUTES + () # Add optional attributes here + + +def sshd_quote(value: str) -> str: + """Quote a string for sshd_config. Values containing whitespace, '#', or + '\"' are wrapped in double quotes, with internal backslashes and double + quotes escaped.""" + if not value: + return '""' + if re.search(r'[\s#"]', value): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return value + + +def to_sshd_value(value) -> str: + """Convert a Python value to an sshd config value. Lists are space-joined, + individual strings are quoted when necessary.""" + if isinstance(value, list): + return " ".join(sshd_quote(v) for v in value) + if isinstance(value, str): + return sshd_quote(value) + raise TypeError(f"Expected str or list[str], got {type(value).__name__}") + + +def try_unlink(path: str): + """Remove a file, ignoring errors if it no longer exists.""" + try: + os.unlink(path) + except OSError: + pass + + +def is_drop_in_directive(directive: str) -> bool: + """Check if a directive is an Include for the drop-in config directory.""" + m = re.match( + rf"include(\s+|\s*=\s*){re.escape(DROP_IN_DIR)}\*\.conf", + directive.strip(), + re.IGNORECASE, + ) + return m is not None + + +def update_result(old: str, new: str) -> str: + """Return the worst of two results. Severity: KEPT < REPAIRED < NOT_KEPT.""" + if old == Result.NOT_KEPT or new == Result.NOT_KEPT: + return Result.NOT_KEPT + if old == Result.REPAIRED or new == Result.REPAIRED: + return Result.REPAIRED + return Result.KEPT + + +def get_first_directive(lines: list[str]) -> str | None: + """Return the first non-comment, non-empty directive, or None if not found.""" + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + return stripped + return None + + +class SshdPromiseTypeModule(PromiseModule): + def __init__(self): + super().__init__("sshd_promise_module", "0.0.0") + + def validate_promise( + self, promiser: str, attributes: dict[str, object], metadata: dict[str, str] + ): + # Check that promiser is a valid sshd keyword + if not re.fullmatch(r"[a-zA-Z0-9]+", promiser): + raise ValidationError( + f"Promiser '{promiser}' must be a valid sshd keyword containing only letters and numbers" + ) + + # Check for unknown attributes + for attr in attributes: + if attr not in ACCEPTED_ATTRIBUTES: + raise ValidationError(f"Attribute '{attr}' is NOT accepted") + + # Check for any missing required attributes + for attr in REQUIRED_ATTRIBUTES: + if attr not in attributes: + raise ValidationError(f"Missing required attribute '{attr}'") + + # Check type of 'value' attributes + value = attributes.get("value") + if not isinstance(value, (str, list)): + raise ValidationError("Attribute 'value' must be a string or an slist") + + # Make sure 'value' attribute is not empty + if not value: + raise ValidationError("Attribute 'value' cannot be empty") + + def validate_config(self, filename: str) -> bool: + """Validate the sshd syntax on a file""" + r = subprocess.run( + ["/usr/sbin/sshd", "-t", "-f", filename], + capture_output=True, + text=True, + ) + if r.returncode != 0: + self.log_error(f"Configuration validation failed: {r.stderr.strip()}") + return False + return True + + def safe_write_config(self, path: str, lines: list[str]) -> bool: + """Atomically write config lines to a temporary file and replace the + target only if sshd validates the syntax successfully.""" + directory = os.path.dirname(path) + base = os.path.basename(path) + prefix, suffix = os.path.splitext(base) + + fd, tmp_path = tempfile.mkstemp(prefix=prefix, suffix=suffix, dir=directory) + try: + os.fchmod(fd, 0o600) # rw------- + with os.fdopen(fd, "w") as f: + f.writelines(lines) + f.flush() # Push data to kernel buffer so sshd can read it + success = self.validate_config(tmp_path) + if success: + os.replace(tmp_path, path) + return success + finally: + try_unlink(tmp_path) + + def ensure_include_directive(self) -> str: + """Ensure the base sshd config includes the drop-in directory.""" + try: + with open(BASE_CONFIG, "r") as f: + lines = f.readlines() + except FileNotFoundError: + self.log_error(f"Base configuration file '{BASE_CONFIG}' does not exist") + return Result.NOT_KEPT + + first_directive = get_first_directive(lines) + + if (first_directive is None) or (not is_drop_in_directive(first_directive)): + include_directive = f"Include {DROP_IN_DIR}*.conf" + self.log_debug( + f"Expected first directive in '{BASE_CONFIG}' to be '{include_directive}'" + ) + + lines.insert(0, f"{include_directive} # Added by CFEngine\n") + try: + if not self.safe_write_config(BASE_CONFIG, lines): + # Error already logged + return Result.NOT_KEPT + except Exception as e: + self.log_error(f"Failed to write '{BASE_CONFIG}': {e}") + return Result.NOT_KEPT + + self.log_info(f"Added include directive to '{BASE_CONFIG}'") + return Result.REPAIRED + + return Result.KEPT + + def ensure_drop_in_dir(self) -> str: + """Ensure the drop-in config directory exists.""" + if os.path.isdir(DROP_IN_DIR): + return Result.KEPT + + try: + os.makedirs(DROP_IN_DIR, mode=0o755) # rwxr-xr-x + except Exception as e: + self.log_error(f"Failed to create drop-in directory '{DROP_IN_DIR}': {e}") + return Result.NOT_KEPT + + self.log_info(f"Created drop-in directory '{DROP_IN_DIR}'") + return Result.REPAIRED + + def ensure_drop_in_config(self, keyword: str, value: str | list[str]) -> str: + """Write the CFEngine drop-in config file with the given attribute""" + + try: + with open(CFE_CONFIG, "r") as f: + lines = f.readlines() + except FileNotFoundError: + lines = [] + + # Remove conflicting directives + lines = [line for line in lines if not line.lower().startswith(keyword.lower())] + + # Remove the disclaimer so that we can put it back on top + lines = [line for line in lines if not line.startswith("# ")] + + # Add the promised directive to the top (just after the disclaimer) + disclaimer = ["# Managed by CFEngine\n", "# Do NOT manually edit this file\n"] + lines = disclaimer + [f"{keyword} {to_sshd_value(value)}\n"] + lines + + try: + if not self.safe_write_config(CFE_CONFIG, lines): + return Result.NOT_KEPT + except Exception as e: + self.log_error(f"Failed to write drop-in config '{CFE_CONFIG}': {e}") + return Result.NOT_KEPT + + self.log_info(f"Updated drop-in config '{CFE_CONFIG}'") + return Result.REPAIRED + + def restart_sshd(self) -> str: + """Restart the sshd service if it is currently running.""" + r = subprocess.run( + ["systemctl", "is-active", "--quiet", "sshd"], + ) + if r.returncode != 0: + # If sshd is not running, do nothing + self.log_debug("The service sshd is not running") + return Result.KEPT + + r = subprocess.run( + ["systemctl", "restart", "--quiet", "sshd"], + ) + if r.returncode != 0: + self.log_error("Failed to restart sshd service") + return Result.NOT_KEPT + + self.log_info("Restarted sshd service") + return Result.REPAIRED + + def effective_config_has_directive( + self, keyword: str, values: str | list[str] + ) -> bool: + """Check if the running sshd effective configuration (via sshd -T) + contains the given directive(s) for a keyword.""" + r = subprocess.run( + ["/usr/sbin/sshd", "-T"], + capture_output=True, + text=True, + ) + if r.returncode != 0: + self.log_error("Failed to get effective sshd config") + return False + effective = r.stdout.strip().splitlines() + + # sshd -T splits multi-argument keywords into separate + # lines (e.g. "AllowUsers user1 user2" becomes two lines: + # "allowusers user1" and "allowusers user2"), so we must + # expand list values into individual directives to match + # the effective config format for set comparison. + + for value in values if isinstance(values, list) else [values]: + assert isinstance(value, str) + directive = f"{keyword.lower()} {to_sshd_value(value)}" + if directive in effective: + self.log_debug( + f"Directive '{directive}' is present in effective sshd config" + ) + else: + self.log_debug( + f"Directive '{directive}' is NOT present in effective sshd config" + ) + return False + + return True + + def verify_effective_config(self, keyword: str, values: str | list[str]) -> str: + """Verify the effective sshd config contains the expected directive, + returning KEPT on success or NOT_KEPT on failure.""" + if self.effective_config_has_directive(keyword, values): + self.log_verbose("Successfully verified effective sshd config") + return Result.KEPT + + self.log_error("Failed to verify effective sshd config") + return Result.NOT_KEPT + + def evaluate_promise( + self, promiser: str, attributes: dict[str, object], metadata: dict[str, str] + ) -> str: + assert "value" in attributes, "expected 'value' in attributes" + value = attributes["value"] + assert isinstance(value, (str, list)), "expected type str or list" + assert value, "expected non-empty str or list" + + # Check if the effective config already has the desired state + if self.effective_config_has_directive(promiser, value): + return Result.KEPT + + # Ensure the base config includes the drop-in directory + result = update_result(Result.KEPT, self.ensure_include_directive()) + + # Ensure the drop-in directory exists + result = update_result(result, self.ensure_drop_in_dir()) + + # Ensure the drop-in config file contains the desired directive + result = update_result(result, self.ensure_drop_in_config(promiser, value)) + + # Restart sshd only if configuration was changed + if result == Result.REPAIRED: + result = update_result(result, self.restart_sshd()) + + # Verify the effective config matches the desired state + result = update_result(result, self.verify_effective_config(promiser, value)) + + return result + + +if __name__ == "__main__": + SshdPromiseTypeModule().start() diff --git a/promise-types/sshd/test_sshd_promise_type.py b/promise-types/sshd/test_sshd_promise_type.py new file mode 100644 index 00000000..32354ef0 --- /dev/null +++ b/promise-types/sshd/test_sshd_promise_type.py @@ -0,0 +1,165 @@ +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "../../libraries/python")) + +from cfengine_module_library import Result # noqa: E402 + +from sshd_promise_type import ( # noqa: E402 + sshd_quote, + to_sshd_value, + get_first_directive, + is_drop_in_directive, + update_result, + DROP_IN_DIR, +) + + +def test_sshd_quote_simple(): + assert sshd_quote("no") == "no" + + +def test_sshd_quote_empty(): + assert sshd_quote("") == '""' + + +def test_sshd_quote_space(): + assert sshd_quote("some value") == '"some value"' + + +def test_sshd_quote_tab(): + assert sshd_quote("some\tvalue") == '"some\tvalue"' + + +def test_sshd_quote_hash(): + assert sshd_quote("before#after") == '"before#after"' + + +def test_sshd_quote_double_quote(): + assert sshd_quote('say "hello"') == '"say \\"hello\\""' + + +def test_sshd_quote_backslash(): + assert sshd_quote("path\\to") == "path\\to" + + +def test_sshd_quote_backslash_and_space(): + assert sshd_quote("path\\to dir") == '"path\\\\to dir"' + + +def test_to_sshd_value_str(): + assert to_sshd_value("no") == "no" + + +def test_to_sshd_value_str_with_spaces(): + assert to_sshd_value("some value") == '"some value"' + + +def test_to_sshd_value_list(): + assert to_sshd_value(["user1", "user2"]) == "user1 user2" + + +def test_to_sshd_value_list_with_quoting(): + assert to_sshd_value(["user1", "user 2"]) == 'user1 "user 2"' + + +def test_get_first_directive(): + lines = ["# comment\n", "PermitRootLogin no\n", "Port 22\n"] + assert get_first_directive(lines) == "PermitRootLogin no" + + +def test_get_first_directive_no_comments(): + lines = ["PermitRootLogin no\n", "Port 22\n"] + assert get_first_directive(lines) == "PermitRootLogin no" + + +def test_get_first_directive_all_comments(): + lines = ["# comment\n", "# another comment\n"] + assert get_first_directive(lines) is None + + +def test_get_first_directive_empty(): + assert get_first_directive([]) is None + + +def test_get_first_directive_extra_whitespace(): + lines = ["# comment\n", "PermitRootLogin no\n"] + assert get_first_directive(lines) == "PermitRootLogin no" + + +def test_get_first_directive_equal_sign(): + lines = ["# comment\n", "PermitRootLogin=no\n"] + assert get_first_directive(lines) == "PermitRootLogin=no" + + +def test_get_first_directive_blank_lines(): + lines = ["\n", " \n", "# comment\n", "Port 22\n"] + assert get_first_directive(lines) == "Port 22" + + +def test_is_drop_in_directive_space(): + assert is_drop_in_directive(f"Include {DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_equal(): + assert is_drop_in_directive(f"Include={DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_space_equal_space(): + assert is_drop_in_directive(f"Include = {DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_case_insensitive(): + assert is_drop_in_directive(f"include {DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_extra_files(): + assert is_drop_in_directive(f"Include {DROP_IN_DIR}*.conf /other/path") + + +def test_is_drop_in_directive_wrong_path(): + assert not is_drop_in_directive("Include /other/path/*.conf") + + +def test_is_drop_in_directive_no_separator(): + assert not is_drop_in_directive(f"Include{DROP_IN_DIR}*.conf") + + +def test_is_drop_in_directive_not_include(): + assert not is_drop_in_directive("permitrootlogin no") + + +def test_update_result_kept_kept(): + assert update_result(Result.KEPT, Result.KEPT) == Result.KEPT + + +def test_update_result_kept_repaired(): + assert update_result(Result.KEPT, Result.REPAIRED) == Result.REPAIRED + + +def test_update_result_kept_not_kept(): + assert update_result(Result.KEPT, Result.NOT_KEPT) == Result.NOT_KEPT + + +def test_update_result_repaired_kept(): + assert update_result(Result.REPAIRED, Result.KEPT) == Result.REPAIRED + + +def test_update_result_repaired_repaired(): + assert update_result(Result.REPAIRED, Result.REPAIRED) == Result.REPAIRED + + +def test_update_result_repaired_not_kept(): + assert update_result(Result.REPAIRED, Result.NOT_KEPT) == Result.NOT_KEPT + + +def test_update_result_not_kept_kept(): + assert update_result(Result.NOT_KEPT, Result.KEPT) == Result.NOT_KEPT + + +def test_update_result_not_kept_repaired(): + assert update_result(Result.NOT_KEPT, Result.REPAIRED) == Result.NOT_KEPT + + +def test_update_result_not_kept_not_kept(): + assert update_result(Result.NOT_KEPT, Result.NOT_KEPT) == Result.NOT_KEPT diff --git a/promise-types/symlinks/README.md b/promise-types/symlinks/README.md new file mode 100644 index 00000000..66cb3c73 --- /dev/null +++ b/promise-types/symlinks/README.md @@ -0,0 +1,53 @@ +The `symlink` promise type enables concise policy for symbolic links. + +## Attributes + +| Name | Type | Description | Default | +|---------------|---------------|-----------------------------------------------------------|---------------| +| `file` | `string` | Path to file. Cannot be used together with `directory`. | - | +| `directory` | `string` | Path to directory. Cannot be used together with `file`. | - | + +## Examples + +To create a symlink to the directory `/tmp/my-dir` with the name `/tmp/my-link`, we can do: + +```cfengine3 +bundle agent main +{ + symlinks: + "/tmp/my-link" + directory => "/tmp/my-dir"; +} +``` + +In similar fashion, to create a symlink to the file `/tmp/my-dir` with the name `/tmp/my-link`, we can do: + +```cfengine3 +bundle agent main +{ + symlinks: + "/tmp/my-link" + file => "/tmp/my-file"; +} +``` + +If the path to the file/directory given in the promise is not an absolute, doesn't exist or its type doesn't correspond with the promise's attribute ("file" or "directory"), then the promise will fail. + +Trying to symlink to a file/directory where the link name is the same as an existing file/directory will also make the promise fail. + +Already exisiting symlinks with incorrect target will be corrected according to the policy. + + +## Authors + +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! + +## Contribute + +Feel free to open pull requests to expand this documentation, add features, or fix problems. +You can also pick up an existing task or file an issue in [our bug tracker](https://northerntech.atlassian.net/). + +## License + +This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text. diff --git a/promise-types/symlinks/enable.cf b/promise-types/symlinks/enable.cf new file mode 100644 index 00000000..39b15f07 --- /dev/null +++ b/promise-types/symlinks/enable.cf @@ -0,0 +1,6 @@ +promise agent symlinks +# @brief Define symlinks promise type +{ + path => "$(sys.workdir)/modules/promises/symlinks.py"; + interpreter => "/usr/bin/python3"; +} diff --git a/promise-types/symlinks/example.cf b/promise-types/symlinks/example.cf new file mode 100644 index 00000000..c2dc4505 --- /dev/null +++ b/promise-types/symlinks/example.cf @@ -0,0 +1,13 @@ +promise agent symlinks +# @brief Define symlinks promise type +{ + path => "$(sys.workdir)/modules/promises/symlinks.py"; + interpreter => "/usr/bin/python3"; +} + +bundle agent main +{ + symlinks: + "/tmp/myfilelink" file => "tmp/myfile"; + "/tmp/mydirlink" directory => "tmp/mydirectory"; +} diff --git a/promise-types/symlinks/symlinks.py b/promise-types/symlinks/symlinks.py new file mode 100644 index 00000000..9dfb2627 --- /dev/null +++ b/promise-types/symlinks/symlinks.py @@ -0,0 +1,117 @@ +import os +from cfengine_module_library import PromiseModule, ValidationError, Result + + +class SymlinksPromiseTypeModule(PromiseModule): + + def __init__(self, **kwargs): + super(SymlinksPromiseTypeModule, self).__init__( + name="symlinks_promise_module", + version="0.0.0", + **kwargs, + ) + + def is_absolute_dir(v): + if not os.path.isabs(v): + raise ValidationError("must be an absolute path, not '{v}'".format(v=v)) + if not os.path.exists(v): + raise ValidationError("directory must exists") + if not os.path.isdir(v): + raise ValidationError("must be a dir") + + def is_absolute_file(v): + if not os.path.isabs(v): + raise ValidationError("must be an absolute path, not '{v}'".format(v=v)) + if not os.path.exists(v): + raise ValidationError("file must exists") + if not os.path.isfile(v): + raise ValidationError("must be a file") + + self.add_attribute("directory", str, validator=is_absolute_dir) + self.add_attribute("file", str, validator=is_absolute_file) + + def validate_promise(self, promiser, attributes, metadata): + model = self.create_attribute_object(promiser, attributes) + + if not model.file and not model.directory: + raise ValidationError("missing 'file' or 'directory' attribute") + + if model.file and model.directory: + raise ValidationError("must specify either 'file' or 'directory', not both") + + def evaluate_promise(self, promiser, attributes, metadata): + model = self.create_attribute_object(promiser, attributes) + link_target = model.file if model.file else model.directory + + try: + os.symlink(link_target, promiser, target_is_directory=bool(model.directory)) + self.log_info("Created symlink '{}' -> '{}'".format(promiser, link_target)) + return Result.REPAIRED + except FileExistsError: + + if not os.path.islink(promiser): + self.log_error("Symlink '{}' is already a path".format(promiser)) + return Result.NOT_KEPT + + if os.path.realpath(promiser) != link_target: + self.log_warning( + "Symlink '{}' already exists but has wrong target '{}'".format( + promiser, os.path.realpath(promiser) + ) + ) + try: + os.unlink(promiser) + except FileNotFoundError: + self.log_error( + "'{}' is already unlinked from its old target".format(promiser) + ) + return Result.NOT_KEPT + except Exception as e: + self.log_error( + "'{}' has wrong target but couldn't be unlinked: {}".format( + promiser, e + ) + ) + return Result.NOT_KEPT + try: + os.symlink( + link_target, promiser, target_is_directory=bool(model.directory) + ) + except FileExistsError: + self.log_error( + "Couldn't symlink '{}' to '{}'. A symlink already exists".format( + link_target, promiser + ) + ) + return Result.NOT_KEPT + except FileNotFoundError: + self.log_error("'{}' doesn't exist".format(link_target)) + return Result.NOT_KEPT + except Exception as e: + self.log_error( + "Couldn't symlink '{}' to '{}': {}".format( + link_target, promiser, e + ) + ) + return Result.NOT_KEPT + + self.log_info( + "Corrected symlink '{}' -> '{}'".format(promiser, link_target) + ) + return Result.REPAIRED + + return Result.KEPT + + except FileNotFoundError: + self.log_error("'{}' doesn't exist".format(promiser)) + return Result.NOT_KEPT + + except Exception as e: + self.log_error( + "Couldn't symlink '{}' to '{}': {}".format(link_target, promiser, e) + ) + return Result.NOT_KEPT + + +if __name__ == "__main__": + SymlinksPromiseTypeModule().start() diff --git a/promise-types/symlinks/test.cf b/promise-types/symlinks/test.cf new file mode 100644 index 00000000..90b23564 --- /dev/null +++ b/promise-types/symlinks/test.cf @@ -0,0 +1,99 @@ +body common control +{ + inputs => { "$(sys.libdir)/stdlib.cf" }; + version => "1.0"; + bundlesequence => { "init", "test", "check", "cleanup" }; +} + +####################################################### +bundle agent init +{ + files: + "/tmp/my-file" create => "true"; + "/tmp/my-dir/." create => "true"; + "/tmp/other-dir/." create => "true"; + "/tmp/replaced-link" link_from => ln_s("/tmp/other-dir"); + "/tmp/already-existing-link" link_from => ln_s("/tmp/other-dir"); +} + +####################################################### +promise agent symlinks +{ + path => "$(this.promise_dirname)/symlinks.py"; + interpreter => "/usr/bin/python3"; +} + +body classes outcome(arg) +{ + promise_kept => { "$(arg)_kept" }; + promise_repaired => { "$(arg)_repaired" }; +} + +bundle agent test +{ + meta: + "description" -> { "CFE-4541" } + string => "Test the symlinks promise module"; + + symlinks: + "/tmp/file-link" + file => "/tmp/my-file", + classes => outcome("created_file"); + + "/tmp/dir-link" + directory => "/tmp/my-dir", + classes => outcome("created_dir"); + + "/tmp/replaced-link" + directory => "/tmp/my-dir", + classes => outcome("corrected"); + + "/tmp/already-existing-link" + directory => "/tmp/other-dir", + classes => outcome("didnothing"); +} + +####################################################### +bundle agent check +{ + vars: + "my_file_stat" string => filestat("/tmp/file-link", "linktarget"); + "my_dir_stat" string => filestat("/tmp/dir-link", "linktarget"); + "replaced_link_stat" string => filestat("/tmp/replaced-link", "linktarget"); + + "already_existing_link_stat" + string => filestat("/tmp/already-existing-link", "linktarget"); + + classes: + "ok" + expression => and( + strcmp("$(my_file_stat)", "/tmp/my-file"), + strcmp("$(my_dir_stat)", "/tmp/my-dir"), + strcmp("$(replaced_link_stat)", "/tmp/my-dir"), + strcmp("$(already_existing_link_stat)", "/tmp/other-dir"), + "created_file_repaired", + "created_dir_repaired", + "corrected_repaired", + "didnothing_kept" + ); + + reports: + ok:: + "$(this.promise_filename) Pass"; + + !ok:: + "$(this.promise_filename) FAIL"; +} + +# ####################################################### +bundle agent cleanup +{ + files: + "/tmp/file-link" delete => tidy; + "/tmp/dir-link" delete => tidy; + "/tmp/my-file" delete => tidy; + "/tmp/my-dir/." delete => tidy; + "/tmp/other-dir/." delete => tidy; + "/tmp/replaced-link" delete => tidy; + "/tmp/already-existing-link" delete => tidy; +} diff --git a/promise-types/systemd/README.md b/promise-types/systemd/README.md index ed85e7d7..952c126f 100644 --- a/promise-types/systemd/README.md +++ b/promise-types/systemd/README.md @@ -1,10 +1,4 @@ -# systemd promise module - -## Synopsis - -* *Name*: `systemd` -* *Version*: `0.1.2` -* *Description*: Create and manage services using systemd +The `systemd` promise type lets you create and manage services using systemd. ## Requirements @@ -99,9 +93,8 @@ bundle agent main ## Authors -This software was created by the team at [Northern.tech AS](https://northern.tech), with many contributions from the community. Thanks everyone! - -[CFEngine](https://cfengine.com) is sponsored by [Northern.tech AS](https://northern.tech) +This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community. +Thanks everyone! ## Contribute diff --git a/promise-types/systemd/example.cf b/promise-types/systemd/example.cf index da3c3da6..1a0595bc 100644 --- a/promise-types/systemd/example.cf +++ b/promise-types/systemd/example.cf @@ -14,7 +14,7 @@ bundle agent main enabled => "true", state => "restarted", unit_description => "my sample service", - service_exec_start => {"/usr/bin/sleep 86400"}, + service_exec_start => { "/usr/bin/sleep 86400" }, service_type => "simple", - install_wanted_by => {"multi-user.target"}; + install_wanted_by => { "multi-user.target" }; } diff --git a/promise-types/systemd/systemd.py b/promise-types/systemd/systemd.py index 6c5f4223..c20ad3fc 100644 --- a/promise-types/systemd/systemd.py +++ b/promise-types/systemd/systemd.py @@ -1,12 +1,10 @@ -import json import os import subprocess from enum import Enum from typing import Dict, List, Optional, Tuple -from cfengine import PromiseModule, ValidationError, Result - +from cfengine_module_library import PromiseModule, Result, AttributeObject SYSTEMD_LIB_PATH = "/lib/systemd/system" @@ -22,7 +20,7 @@ class SystemdPromiseTypeStates(Enum): class SystemdPromiseTypeModule(PromiseModule): def __init__(self, **kwargs): super(SystemdPromiseTypeModule, self).__init__( - "systemd_promise_module", "0.2.2", **kwargs + "systemd_promise_module", "0.0.0", **kwargs ) def state_must_be_valid(v): @@ -81,9 +79,9 @@ def prepare_promiser_and_attributes(self, promiser, attributes): return (safe_promiser, attributes) def evaluate_promise( - self, safe_promiser: str, attributes: Dict, meta: Dict + self, promiser: str, attributes: Dict, metadata: Dict ) -> Tuple[str, List[str]]: - model = self.create_attribute_object(safe_promiser, attributes) + model = self.create_attribute_object(promiser, attributes) # get the status of the service try: output = self._exec_command( @@ -104,19 +102,20 @@ def evaluate_promise( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, - ["{safe_promiser}_show_failed".format(safe_promiser=safe_promiser)], + ["{safe_promiser}_show_failed".format(safe_promiser=promiser)], ) # apply the changes if model.state == SystemdPromiseTypeStates.ABSENT.value: - return self._service_absent(model, safe_promiser, service_status) + return self._service_absent(model, promiser, service_status) else: - return self._service_present(model, safe_promiser, service_status) + return self._service_present(model, promiser, service_status) def _service_absent( - self, model: object, safe_promiser: str, service_status: dict + self, model: AttributeObject, safe_promiser: str, service_status: dict ) -> Tuple[str, List[str]]: classes = [] result = Result.KEPT @@ -131,7 +130,8 @@ def _service_absent( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, ["{safe_promiser}_stop_failed".format(safe_promiser=safe_promiser)], @@ -147,7 +147,8 @@ def _service_absent( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -190,7 +191,8 @@ def _service_absent( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -204,7 +206,7 @@ def _service_absent( return (result, classes) def _service_present( - self, model: object, safe_promiser: str, service_status: dict + self, model: AttributeObject, safe_promiser: str, service_status: dict ) -> Tuple[str, List[str]]: classes = [] result = Result.KEPT @@ -245,7 +247,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -266,7 +269,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -287,7 +291,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, ["{safe_promiser}_mask_failed".format(safe_promiser=safe_promiser)], @@ -304,7 +309,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -331,7 +337,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -358,7 +365,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -384,7 +392,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -411,7 +420,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, ["{safe_promiser}_stop_failed".format(safe_promiser=safe_promiser)], @@ -430,7 +440,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -453,7 +464,8 @@ def _service_present( self.log_error( "Failed to run systemctl: {error}".format(error=e.output or e) ) - e.stderr and self.log_error(e.stderr.strip()) + if e.stderr: + self.log_error(e.stderr.strip()) return ( Result.NOT_KEPT, [ @@ -477,10 +489,11 @@ def _exec_command(self, args: List[str], cwd: Optional[str] = None) -> str: .strip() .decode("utf-8") ) - output != "" and self.log_verbose(output) + if output != "": + self.log_verbose(output) return output - def _render_service_template(self, model: object) -> str: + def _render_service_template(self, model: AttributeObject) -> str: blocks = { "unit": [], "service": [], @@ -518,7 +531,7 @@ def _render_service_template(self, model: object) -> str: value = getattr(model, attr) if value is None: continue - elif type(value) == list: + elif isinstance(value, list): for item in value: blocks[block].append("{key}={item}".format(key=key, item=item)) else: diff --git a/promise-types/systemd/test.cf b/promise-types/systemd/test.cf index a5b21fc1..426d9d79 100644 --- a/promise-types/systemd/test.cf +++ b/promise-types/systemd/test.cf @@ -4,18 +4,15 @@ body common control } ####################################################### - bundle agent init { - } ####################################################### - promise agent systemd { - path => "$(this.promise_dirname)/systemd.py"; - interpreter => "/usr/bin/python3"; + path => "$(this.promise_dirname)/systemd.py"; + interpreter => "/usr/bin/python3"; } bundle agent test @@ -31,40 +28,38 @@ bundle agent test enabled => "true", state => "restarted", unit_description => "my sample service", - service_exec_start => {"/usr/bin/sleep 86400"}, + service_exec_start => { "/usr/bin/sleep 86400" }, service_type => "simple", - install_wanted_by => {"multi-user.target"}; + install_wanted_by => { "multi-user.target" }; classes: - "promise_repaired" - expression => canonify("sample_restarted"), - scope => "namespace"; + "promise_repaired" + expression => canonify("sample_restarted"), + scope => "namespace"; } ####################################################### - bundle agent check { classes: - "file_ok" - if => fileexists("/lib/systemd/system/sample.service"); - - "ok" expression => "file_ok.promise_repaired"; + "file_ok" if => fileexists("/lib/systemd/system/sample.service"); + "ok" expression => "file_ok.promise_repaired"; reports: DEBUG.file_ok:: "file_ok"; + DEBUG.promise_repaired:: "promise_repaired"; ok:: "$(this.promise_filename) Pass"; + !ok:: "$(this.promise_filename) FAIL"; } ####################################################### - bundle agent cleanup { systemd: diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000..c9566077 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "reportMissingImports": "none", + "extraPaths": ["libraries/python"] +} diff --git a/reporting/compliance-report-fwupd/README.org b/reporting/compliance-report-fwupd/README.org new file mode 100644 index 00000000..726872c0 --- /dev/null +++ b/reporting/compliance-report-fwupd/README.org @@ -0,0 +1,138 @@ +Compliance report definition for firmware security posture via fwupd. + +Imports a "Firmware Security (fwupd)" report into Mission Portal that +tracks Host Security Identifier (HSI) levels, fwupd installation, and +firmware update status across the fleet. + +The report contains 49 conditions: 7 rolled-up checks (HSI level +thresholds, fwupd installation, update status) and 42 per-attribute +checks covering every individual HSI firmware security test. + +* Mission Portal + +[[https://raw.githubusercontent.com/cfengine/modules/master/reporting/compliance-report-fwupd/mp-compliance-report-fwupd.png]] + +* Requirements + +- *inventory-fwupd* module providing the inventory attributes and + platform classes (=fwupd_cpu_vendor_intel=, =fwupd_cpu_vendor_amd=, + =fwupd_oem_vendor_hp=) that the compliance conditions reference +- *compliance-report-imports* autorun bundle on the hub to import + the JSON definition into Mission Portal + +* Rolled-up Conditions + +| Condition | Category | Severity | +|-------------------------------+--------------------+----------| +| *fwupd installed* | Firmware tooling | high | +| *HSI Level 1+ (Critical)* | HSI Level Overview | high | +| *HSI Level 2+ (Important)* | HSI Level Overview | medium | +| *HSI Level 3+ (Recommended)* | HSI Level Overview | low | +| *HSI Level 4 (Complete)* | HSI Level Overview | low | +| *No pending firmware updates* | Firmware updates | medium | +| *Firmware status healthy* | Firmware updates | medium | + +HSI level checks are cumulative thresholds -- a host at HSI:3 passes +the Level 1+, 2+, and 3+ conditions but fails Level 4. + +* Per-Attribute Conditions + +Each individual HSI firmware security check is a separate compliance +condition. Every one matches an exact item against the +*Firmware HSI failing* slist from *inventory-fwupd*, whose items follow +the pattern =L: =. + +Per-attribute conditions use ~condition_for: "failing"~ -- a host is +only marked failing when the check appears in that list. Hosts that +don't report a given check (e.g., Intel-only checks on AMD hardware, or +VMs without HSI data) show as "not evaluated" rather than failing, +because the check appears in neither the failing nor the passing list. The checks are defined by the +[[https://fwupd.github.io/libfwupdplugin/hsi.html][fwupd HSI specification]]. + +** Level 1 -- Critical (19 conditions, severity: high) + +| Condition | List item | Platform | +|------------------------------+----------------------------------+----------| +| UEFI SecureBoot | =L1: UEFI secure boot= | All | +| TPM 2.0 Present | =L1: TPM v2.0= | All | +| Empty PCR in TPM | =L1: TPM empty PCRs= | All | +| UEFI Platform Key | =L1: UEFI platform key= | All | +| BIOS Capsule Updates | =L1: BIOS firmware updates= | All | +| Supported CPU | =L1: Supported CPU= | All | +| UEFI BootService Variables | =L1: UEFI bootservice variables= | All | +| BIOS Write Enable (BWE) | =L1: SPI write= | Intel | +| BIOS Lock Enable (BLE) | =L1: SPI lock= | Intel | +| SMM BIOS Write Protect | =L1: SPI BIOS region= | Intel | +| Read-only SPI Descriptor | =L1: SPI descriptor= | Intel | +| Platform Debug (Intel DCI) | =L1: Platform debugging= | Intel | +| ME Manufacturing Mode | =L1: csme manufacturing mode= | Intel | +| ME Flash Descriptor Override | =L1: csme override= | Intel | +| ME BootGuard Platform Key | =L1: MEI key manifest= | Intel | +| CSME Version | =L1: CSME version= | Intel | +| Part is Fused | =L1: Part is fused= | Intel | +| AMD Microcode Signature | =L1: AMD microcode signature= | AMD | +| SMM Locked Down | =L1: SMM locked down= | AMD | + +** Level 2 -- Important (12 conditions, severity: medium) + +| Condition | List item | Platform | +|---------------------------+-------------------------------------+----------| +| DMA Protection (IOMMU) | =L2: IOMMU= | All | +| PCR0 TPM Event Log | =L2: TPM PCR0 reconstruction= | All | +| BIOS Rollback Protection | =L2: BIOS rollback protection= | All | +| Intel BootGuard Enabled | =L2: Intel BootGuard= | Intel | +| Intel BootGuard Verified | =L2: Intel BootGuard verified boot= | Intel | +| Intel BootGuard ACM | =L2: Intel BootGuard ACM protected= | Intel | +| Intel BootGuard OTP | =L2: Intel BootGuard OTP fuse= | Intel | +| Part is Debug Locked | =L2: Platform debugging= | Intel | +| Intel GDS Mitigation | =L2: Intel GDS mitigation= | Intel | +| AMD Platform Secure Boot | =L2: AMD platform secure boot= | AMD | +| AMD SPI Write Protections | =L2: AMD SPI write protections= | AMD | +| HP SureStart | =L2: HP SureStart= | HP | + +** Level 3 -- Recommended (8 conditions, severity: low) + +| Condition | List item | Platform | +|------------------------------------+------------------------------------+----------| +| Suspend-to-Idle | =L3: Suspend-to-idle= | All | +| Suspend to RAM Disabled | =L3: Suspend-to-ram= | All | +| Pre-boot DMA Protection | =L3: Pre-boot DMA protection= | All | +| CET Available | =L3: CET Platform= | All | +| CET Utilized by OS | =L3: CET OS Support= | All | +| Early-boot UEFI Memory Protections | =L3: UEFI memory protections= | All | +| Intel BootGuard Policy | =L3: Intel BootGuard error policy= | Intel | +| AMD SPI Replay Protections | =L3: AMD SPI replay protections= | AMD | + +** Level 4 -- Complete (3 conditions, severity: low) + +| Condition | List item | Platform | +|-------------------------------+-------------------------------+----------| +| DRAM Memory Encryption | =L4: Encrypted RAM= | All | +| SMAP | =L4: SMAP= | All | +| AMD Secure Processor Rollback | =L4: AMD rollback protection= | AMD | + +* Categories + +| Category | Conditions | Scope | +|---------------------------+------------+----------------------------| +| HSI Level Overview | 4 | Rolled-up level thresholds | +| HSI Level 1 - Critical | 19 | Per-attribute checks | +| HSI Level 2 - Important | 12 | Per-attribute checks | +| HSI Level 3 - Recommended | 8 | Per-attribute checks | +| HSI Level 4 - Complete | 3 | Per-attribute checks | +| Firmware updates | 2 | Update status | +| Firmware tooling | 1 | fwupd installation | + +* Platform support + +Linux only. Platform-specific conditions use =host_filter= class +expressions so they only activate on relevant hardware: + +| Platform | =host_filter= | Conditions | Detection | +|----------+--------------------------+------------+--------------------------------| +| All | =linux= | 25 | -- | +| Intel | =fwupd_cpu_vendor_intel= | 17 | =/proc/cpuinfo= vendor_id | +| AMD | =fwupd_cpu_vendor_amd= | 6 | =/proc/cpuinfo= vendor_id | +| HP | =fwupd_oem_vendor_hp= | 1 | =/sys/class/dmi/id/sys_vendor= | + +These classes are defined by the *inventory-fwupd* module. diff --git a/reporting/compliance-report-fwupd/fwupd-compliance-report.json b/reporting/compliance-report-fwupd/fwupd-compliance-report.json new file mode 100644 index 00000000..f2a83477 --- /dev/null +++ b/reporting/compliance-report-fwupd/fwupd-compliance-report.json @@ -0,0 +1,895 @@ +{ + "reports": { + "fwupd-firmware-security": { + "id": "fwupd-firmware-security", + "type": "compliance", + "title": "Firmware Security (fwupd)", + "conditions": [ + "fwupd:hsi-installed", + "fwupd:hsi-level-1", + "fwupd:hsi-level-2", + "fwupd:hsi-level-3", + "fwupd:hsi-level-4", + "fwupd:no-pending-updates", + "fwupd:status-ok", + "fwupd:hsi-l1-uefi-secure-boot", + "fwupd:hsi-l1-tpm-v2.0", + "fwupd:hsi-l1-tpm-empty-pcrs", + "fwupd:hsi-l1-uefi-platform-key", + "fwupd:hsi-l1-bios-firmware-updates", + "fwupd:hsi-l1-spi-write", + "fwupd:hsi-l1-spi-lock", + "fwupd:hsi-l1-spi-bios-region", + "fwupd:hsi-l1-spi-descriptor", + "fwupd:hsi-l1-supported-cpu", + "fwupd:hsi-l1-platform-debugging", + "fwupd:hsi-l1-csme-manufacturing-mode", + "fwupd:hsi-l1-csme-override", + "fwupd:hsi-l1-mei-key-manifest", + "fwupd:hsi-l1-csme-version", + "fwupd:hsi-l1-uefi-bootservice-vars", + "fwupd:hsi-l1-amd-microcode-signature", + "fwupd:hsi-l1-platform-fused", + "fwupd:hsi-l1-smm-locked-down", + "fwupd:hsi-l2-iommu", + "fwupd:hsi-l2-intel-bootguard", + "fwupd:hsi-l2-intel-bootguard-verified", + "fwupd:hsi-l2-intel-bootguard-acm", + "fwupd:hsi-l2-intel-bootguard-otp", + "fwupd:hsi-l2-tpm-pcr0-reconstruction", + "fwupd:hsi-l2-bios-rollback-protection", + "fwupd:hsi-l2-platform-debugging", + "fwupd:hsi-l2-intel-gds-mitigation", + "fwupd:hsi-l2-amd-platform-secure-boot", + "fwupd:hsi-l2-amd-spi-write-protections", + "fwupd:hsi-l2-hp-surestart", + "fwupd:hsi-l3-suspend-to-idle", + "fwupd:hsi-l3-suspend-to-ram", + "fwupd:hsi-l3-pre-boot-dma-protection", + "fwupd:hsi-l3-intel-bootguard-policy", + "fwupd:hsi-l3-cet-platform", + "fwupd:hsi-l3-amd-spi-replay-protections", + "fwupd:hsi-l3-uefi-memory-protections", + "fwupd:hsi-l4-encrypted-ram", + "fwupd:hsi-l4-smap", + "fwupd:hsi-l3-cet-os-support", + "fwupd:hsi-l4-amd-rollback-protection" + ] + } + }, + "conditions": { + "fwupd:hsi-installed": { + "id": "fwupd:hsi-installed", + "name": "fwupd installed", + "description": "Checks that fwupd is installed on the host.\n\nHosts without fwupd report a firmware update status of FWUPD_MISSING and cannot provide firmware security inventory. This is a prerequisite for all other firmware security checks.", + "type": "inventory", + "condition_for": "passing", + "rules": [ + { + "attribute": "Firmware update status", + "operator": "not_match", + "value": "FWUPD_MISSING" + } + ], + "category": "Firmware tooling", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-level-1": { + "id": "fwupd:hsi-level-1", + "name": "HSI Level 1+ - Critical firmware protections", + "description": "Rolled-up check: the host achieves at least HSI Level 1 (Critical). See the individual Level 1 conditions for specific checks.", + "type": "inventory", + "condition_for": "passing", + "rules": [ + { + "attribute": "Firmware HSI level", + "operator": "not_match", + "value": "HSI:0" + } + ], + "category": "HSI Level Overview", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-level-2": { + "id": "fwupd:hsi-level-2", + "name": "HSI Level 2+ - Important firmware protections", + "description": "Rolled-up check: the host achieves at least HSI Level 2 (Important). See the individual Level 2 conditions for specific checks.", + "type": "inventory", + "condition_for": "passing", + "rules": [ + { + "attribute": "Firmware HSI level", + "operator": "regex_matches", + "value": "HSI:[2-4]" + } + ], + "category": "HSI Level Overview", + "severity": "medium", + "host_filter": "linux" + }, + "fwupd:hsi-level-3": { + "id": "fwupd:hsi-level-3", + "name": "HSI Level 3+ - Recommended firmware protections", + "description": "Rolled-up check: the host achieves at least HSI Level 3 (Recommended). See the individual Level 3 conditions for specific checks.", + "type": "inventory", + "condition_for": "passing", + "rules": [ + { + "attribute": "Firmware HSI level", + "operator": "regex_matches", + "value": "HSI:[3-4]" + } + ], + "category": "HSI Level Overview", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-level-4": { + "id": "fwupd:hsi-level-4", + "name": "HSI Level 4 - Complete firmware protections", + "description": "Rolled-up check: the host achieves HSI Level 4 (Complete). See the individual Level 4 conditions for specific checks.", + "type": "inventory", + "condition_for": "passing", + "rules": [ + { + "attribute": "Firmware HSI level", + "operator": "matches", + "value": "HSI:4" + } + ], + "category": "HSI Level Overview", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:no-pending-updates": { + "id": "fwupd:no-pending-updates", + "name": "No pending firmware updates", + "description": "Checks that the host has no outstanding firmware updates.\n\nHosts with pending firmware updates have known firmware versions that should be applied. Use the manage-fwupd module to automate firmware update application.", + "type": "inventory", + "condition_for": "passing", + "rules": [ + { + "attribute": "Firmware update status", + "operator": "not_match", + "value": "UPDATES_AVAILABLE" + } + ], + "category": "Firmware updates", + "severity": "medium", + "host_filter": "linux" + }, + "fwupd:status-ok": { + "id": "fwupd:status-ok", + "name": "Firmware status healthy", + "description": "Checks that fwupd is present, has discoverable devices, and no pending updates.\n\nThis is the strictest firmware status check. A host passes only when fwupd is installed, reports at least one updatable device, and has no outstanding firmware updates.", + "type": "inventory", + "condition_for": "passing", + "rules": [ + { + "attribute": "Firmware update status", + "operator": "matches", + "value": "OK" + } + ], + "category": "Firmware updates", + "severity": "medium", + "host_filter": "linux" + }, + "fwupd:hsi-l1-uefi-secure-boot": { + "id": "fwupd:hsi-l1-uefi-secure-boot", + "name": "UEFI SecureBoot", + "description": "Secure Boot is enabled and functional.\n\nUEFI Secure Boot prevents unauthorized code from running during the boot process by verifying digital signatures of boot loaders and drivers.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: UEFI secure boot" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-l1-tpm-v2.0": { + "id": "fwupd:hsi-l1-tpm-v2.0", + "name": "TPM 2.0 Present", + "description": "TPM 2.0 device exists and is enabled.\n\nA Trusted Platform Module provides hardware-backed cryptographic operations and secure measurement storage for verified boot.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: TPM v2.0" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-l1-tpm-empty-pcrs": { + "id": "fwupd:hsi-l1-tpm-empty-pcrs", + "name": "Empty PCR in TPM", + "description": "All TPM PCRs 0-7 have valid measurements.\n\nEmpty Platform Configuration Registers indicate that the firmware boot process is not being measured, weakening the chain of trust.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: TPM empty PCRs" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-l1-uefi-platform-key": { + "id": "fwupd:hsi-l1-uefi-platform-key", + "name": "UEFI Platform Key", + "description": "Valid production platform key is installed (not a test key).\n\nThe UEFI Platform Key (PK) is the root of trust for Secure Boot. A test key allows any code to be signed and run.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: UEFI platform key" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-l1-bios-firmware-updates": { + "id": "fwupd:hsi-l1-bios-firmware-updates", + "name": "BIOS Capsule Updates", + "description": "Firmware update mechanism (UEFI capsule updates) is enabled.\n\nWhen disabled, the system cannot receive firmware security patches through the standard UEFI update mechanism.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: BIOS firmware updates" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-l1-spi-write": { + "id": "fwupd:hsi-l1-spi-write", + "name": "BIOS Write Enable (BWE)", + "description": "SPI ROM is protected from userspace writes.\n\nThe BIOS Write Enable bit controls whether the SPI flash containing the BIOS can be written from the operating system. It should be disabled to prevent firmware tampering.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: SPI write" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-spi-lock": { + "id": "fwupd:hsi-l1-spi-lock", + "name": "BIOS Lock Enable (BLE)", + "description": "SMI protection on BIOSWE bit changes is enabled.\n\nBIOS Lock Enable generates a System Management Interrupt when software attempts to change the BIOS Write Enable bit, preventing silent firmware tampering.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: SPI lock" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-spi-bios-region": { + "id": "fwupd:hsi-l1-spi-bios-region", + "name": "SMM BIOS Write Protect", + "description": "BIOS region is non-writable outside System Management Mode.\n\nSMM BIOS Write Protect ensures that the BIOS SPI flash region cannot be modified except from within the trusted SMM environment.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: SPI BIOS region" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-spi-descriptor": { + "id": "fwupd:hsi-l1-spi-descriptor", + "name": "Read-only SPI Descriptor", + "description": "SPI flash descriptor region is locked.\n\nThe SPI descriptor defines access permissions for SPI flash regions. If unlocked, an attacker could modify these permissions to gain write access to the BIOS.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: SPI descriptor" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-supported-cpu": { + "id": "fwupd:hsi-l1-supported-cpu", + "name": "Supported CPU", + "description": "Platform CPU has HSI test support in fwupd.\n\nUnsupported CPUs cannot have their firmware security attributes fully evaluated.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: Supported CPU" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-l1-platform-debugging": { + "id": "fwupd:hsi-l1-platform-debugging", + "name": "Platform Debug (Intel DCI)", + "description": "USB3 debugging interface (Intel DCI) is disabled.\n\nDirect Connect Interface allows deep hardware debugging over USB3. If enabled in production, it provides an attack surface for physical access exploits.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: Platform debugging" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-csme-manufacturing-mode": { + "id": "fwupd:hsi-l1-csme-manufacturing-mode", + "name": "ME Manufacturing Mode", + "description": "Intel Management Engine is not in manufacturing mode.\n\nManufacturing mode is intended for factory provisioning. If left enabled, it exposes additional attack surface on the ME subsystem.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: csme manufacturing mode" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-csme-override": { + "id": "fwupd:hsi-l1-csme-override", + "name": "ME Flash Descriptor Override", + "description": "Intel ME flash descriptor override (debug mode) is not accessible.\n\nThe override strap allows bypassing SPI flash access controls, enabling firmware tampering through physical access.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: csme override" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-mei-key-manifest": { + "id": "fwupd:hsi-l1-mei-key-manifest", + "name": "ME BootGuard Platform Key", + "description": "CPU fuses are not using leaked or compromised keys.\n\nThe MEI key manifest validates that the BootGuard keys burned into the CPU fuses have not been compromised.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: MEI key manifest" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-csme-version": { + "id": "fwupd:hsi-l1-csme-version", + "name": "CSME Version", + "description": "Intel Converged Security and Management Engine firmware is not vulnerable to known critical CVEs.\n\nOutdated CSME versions may contain exploitable vulnerabilities that compromise the hardware root of trust.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: CSME version" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-uefi-bootservice-vars": { + "id": "fwupd:hsi-l1-uefi-bootservice-vars", + "name": "UEFI BootService Variables", + "description": "Boot-only UEFI variables are not readable at OS runtime.\n\nBootService variables should be inaccessible after ExitBootServices() to prevent information leakage to the operating system.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: UEFI bootservice variables" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "linux" + }, + "fwupd:hsi-l1-amd-microcode-signature": { + "id": "fwupd:hsi-l1-amd-microcode-signature", + "name": "AMD Microcode Signature", + "description": "Firmware prevents loading of maliciously crafted CPU microcode.\n\nAMD processors vulnerable to microcode signature verification bypass (EntrySign) allow arbitrary microcode patches, undermining hardware security guarantees.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: AMD microcode signature" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_amd" + }, + "fwupd:hsi-l1-platform-fused": { + "id": "fwupd:hsi-l1-platform-fused", + "name": "Part is Fused", + "description": "Hardware one-time-programmable fuses are blown for tampering protection.\n\nProduction hardware should have security fuses blown to lock down debugging interfaces and enforce boot chain verification.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: Part is fused" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l1-smm-locked-down": { + "id": "fwupd:hsi-l1-smm-locked-down", + "name": "SMM Locked Down", + "description": "System Management Mode save state protection prevents data exfiltration.\n\nSMM lockdown prevents SMM handlers from reading or modifying OS memory, closing a class of privilege escalation attacks.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L1: SMM locked down" + } + ], + "category": "HSI Level 1 - Critical", + "severity": "high", + "host_filter": "fwupd_cpu_vendor_amd" + }, + "fwupd:hsi-l2-iommu": { + "id": "fwupd:hsi-l2-iommu", + "name": "DMA Protection (IOMMU)", + "description": "IOMMU is enabled for PCIe DMA attack prevention.\n\nThe IOMMU restricts DMA-capable devices to their assigned memory regions, preventing malicious peripherals from reading or writing arbitrary system memory.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: IOMMU" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "linux" + }, + "fwupd:hsi-l2-intel-bootguard": { + "id": "fwupd:hsi-l2-intel-bootguard", + "name": "Intel BootGuard Enabled", + "description": "Intel BootGuard feature is activated.\n\nBootGuard provides hardware-rooted verified boot by validating the Initial Boot Block before execution.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: Intel BootGuard" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l2-intel-bootguard-verified": { + "id": "fwupd:hsi-l2-intel-bootguard-verified", + "name": "Intel BootGuard Verified", + "description": "Intel BootGuard boot chain verification is functional.\n\nVerified Boot mode ensures each stage of the boot process is cryptographically validated before execution.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: Intel BootGuard verified boot" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l2-intel-bootguard-acm": { + "id": "fwupd:hsi-l2-intel-bootguard-acm", + "name": "Intel BootGuard ACM", + "description": "Intel Authenticated Code Modules are protected.\n\nACMs are signed microcode modules executed by the CPU before any other code. Their integrity is essential for the hardware root of trust.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: Intel BootGuard ACM protected" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l2-intel-bootguard-otp": { + "id": "fwupd:hsi-l2-intel-bootguard-otp", + "name": "Intel BootGuard OTP", + "description": "SOC is locked via one-time programmable fuses for BootGuard.\n\nOTP fuses permanently bind the BootGuard key hash to the CPU, preventing key substitution attacks.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: Intel BootGuard OTP fuse" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l2-tpm-pcr0-reconstruction": { + "id": "fwupd:hsi-l2-tpm-pcr0-reconstruction", + "name": "PCR0 TPM Event Log", + "description": "TPM PCR0 value matches the reconstructed firmware measurements.\n\nPCR0 reconstruction verifies that the TPM event log is consistent with the actual PCR values, detecting tampering with boot measurements.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: TPM PCR0 reconstruction" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "linux" + }, + "fwupd:hsi-l2-bios-rollback-protection": { + "id": "fwupd:hsi-l2-bios-rollback-protection", + "name": "BIOS Rollback Protection", + "description": "BIOS prevents installing older vulnerable firmware versions.\n\nWithout rollback protection, an attacker with firmware write access could downgrade to a version with known vulnerabilities.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: BIOS rollback protection" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "linux" + }, + "fwupd:hsi-l2-platform-debugging": { + "id": "fwupd:hsi-l2-platform-debugging", + "name": "Part is Debug Locked", + "description": "Hardware debugging access is locked.\n\nDebug interfaces (JTAG, etc.) allow full hardware access. In production systems these should be permanently locked to prevent physical attack vectors.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: Platform debugging" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l2-intel-gds-mitigation": { + "id": "fwupd:hsi-l2-intel-gds-mitigation", + "name": "Intel GDS Mitigation", + "description": "CPU microcode patches mitigate Gather Data Sampling.\n\nGDS (Downfall) is a transient execution side-channel vulnerability in Intel CPUs that can leak data across security boundaries.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: Intel GDS mitigation" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l2-amd-platform-secure-boot": { + "id": "fwupd:hsi-l2-amd-platform-secure-boot", + "name": "AMD Platform Secure Boot", + "description": "AMD Platform Secure Boot prevents non-manufacturer firmware execution.\n\nThe AMD Secure Processor validates firmware authenticity before allowing execution, providing a hardware root of trust.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: AMD platform secure boot" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_cpu_vendor_amd" + }, + "fwupd:hsi-l2-amd-spi-write-protections": { + "id": "fwupd:hsi-l2-amd-spi-write-protections", + "name": "AMD SPI Write Protections", + "description": "SPI bus write control prevents unauthorized firmware changes on AMD systems.\n\nAMD SPI write protections lock the SPI flash to prevent unauthorized firmware modifications.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: AMD SPI write protections" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_cpu_vendor_amd" + }, + "fwupd:hsi-l2-hp-surestart": { + "id": "fwupd:hsi-l2-hp-surestart", + "name": "HP SureStart", + "description": "HP SureStart BIOS self-healing and corruption detection is enabled.\n\nHP SureStart monitors the BIOS for corruption or tampering and automatically restores it from a protected copy.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L2: HP SureStart" + } + ], + "category": "HSI Level 2 - Important", + "severity": "medium", + "host_filter": "fwupd_oem_vendor_hp" + }, + "fwupd:hsi-l3-suspend-to-idle": { + "id": "fwupd:hsi-l3-suspend-to-idle", + "name": "Suspend-to-Idle", + "description": "Default sleep state is suspend-to-idle (S0ix) rather than S3.\n\nSuspend-to-idle keeps the IOMMU and other security features active during sleep, unlike S3 which powers down protections.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L3: Suspend-to-idle" + } + ], + "category": "HSI Level 3 - Recommended", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-l3-suspend-to-ram": { + "id": "fwupd:hsi-l3-suspend-to-ram", + "name": "Suspend to RAM Disabled", + "description": "S3 suspend-to-RAM sleep is not available or not the default.\n\nS3 sleep powers down the IOMMU, disabling DMA protections and creating a window for cold boot or DMA attacks on resume.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L3: Suspend-to-ram" + } + ], + "category": "HSI Level 3 - Recommended", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-l3-pre-boot-dma-protection": { + "id": "fwupd:hsi-l3-pre-boot-dma-protection", + "name": "Pre-boot DMA Protection", + "description": "IOMMU is configured for pre-boot DMA attack mitigation.\n\nPre-boot DMA protection ensures the IOMMU is active before the OS loads, preventing DMA attacks during early boot when the OS IOMMU driver is not yet running.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L3: Pre-boot DMA protection" + } + ], + "category": "HSI Level 3 - Recommended", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-l3-intel-bootguard-policy": { + "id": "fwupd:hsi-l3-intel-bootguard-policy", + "name": "Intel BootGuard Policy", + "description": "BootGuard error enforcement policy halts boot on verification failure.\n\nIn enforcement mode, BootGuard stops the boot process if signature verification fails rather than continuing with potentially compromised firmware.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L3: Intel BootGuard error policy" + } + ], + "category": "HSI Level 3 - Recommended", + "severity": "low", + "host_filter": "fwupd_cpu_vendor_intel" + }, + "fwupd:hsi-l3-cet-platform": { + "id": "fwupd:hsi-l3-cet-platform", + "name": "CET Available", + "description": "Control Flow Enforcement Technology is supported and enabled.\n\nCET provides hardware-enforced shadow stacks and indirect branch tracking to mitigate return-oriented programming (ROP) and jump-oriented programming (JOP) attacks.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L3: CET Platform" + } + ], + "category": "HSI Level 3 - Recommended", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-l3-amd-spi-replay-protections": { + "id": "fwupd:hsi-l3-amd-spi-replay-protections", + "name": "AMD SPI Replay Protections", + "description": "Monotonic counter prevents SPI flash replay attacks on AMD systems.\n\nSPI replay protection uses a hardware counter to detect when an older SPI flash image has been re-applied, preventing firmware downgrade attacks.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L3: AMD SPI replay protections" + } + ], + "category": "HSI Level 3 - Recommended", + "severity": "low", + "host_filter": "fwupd_cpu_vendor_amd" + }, + "fwupd:hsi-l3-uefi-memory-protections": { + "id": "fwupd:hsi-l3-uefi-memory-protections", + "name": "Early-boot UEFI Memory Protections", + "description": "NX and read-only protections are applied to UEFI boot memory.\n\nEarly-boot memory protections prevent code injection and data corruption during the UEFI boot phase by enforcing W^X (write XOR execute) policies.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L3: UEFI memory protections" + } + ], + "category": "HSI Level 3 - Recommended", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-l4-encrypted-ram": { + "id": "fwupd:hsi-l4-encrypted-ram", + "name": "DRAM Memory Encryption", + "description": "Total Memory Encryption (Intel TME) or Secure Memory Encryption (AMD SME) encrypts all data on the memory bus.\n\nMemory encryption protects against physical memory access attacks including cold boot attacks and DMA-based memory scraping.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L4: Encrypted RAM" + } + ], + "category": "HSI Level 4 - Complete", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-l4-smap": { + "id": "fwupd:hsi-l4-smap", + "name": "SMAP", + "description": "Supervisor Mode Access Prevention is enabled.\n\nSMAP prevents the kernel from accidentally accessing user-space memory, mitigating a class of privilege escalation exploits that trick the kernel into dereferencing user-controlled pointers.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L4: SMAP" + } + ], + "category": "HSI Level 4 - Complete", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-l3-cet-os-support": { + "id": "fwupd:hsi-l3-cet-os-support", + "name": "CET Utilized by OS", + "description": "The operating system actively uses Control Flow Enforcement Technology.\n\nWhile CET hardware support (Level 3) is necessary, the OS must also enable and use it for the protection to be effective.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L3: CET OS Support" + } + ], + "category": "HSI Level 3 - Recommended", + "severity": "low", + "host_filter": "linux" + }, + "fwupd:hsi-l4-amd-rollback-protection": { + "id": "fwupd:hsi-l4-amd-rollback-protection", + "name": "AMD Secure Processor Rollback", + "description": "AMD Secure Processor prevents firmware downgrade attacks.\n\nThe AMD Platform Security Processor maintains anti-rollback counters that prevent installation of older firmware versions with known vulnerabilities.", + "type": "inventory", + "condition_for": "failing", + "rules": [ + { + "attribute": "Firmware HSI failing", + "operator": "matches", + "value": "L4: AMD rollback protection" + } + ], + "category": "HSI Level 4 - Complete", + "severity": "low", + "host_filter": "fwupd_cpu_vendor_amd" + } + } +} diff --git a/reporting/compliance-report-fwupd/mp-compliance-report-fwupd.png b/reporting/compliance-report-fwupd/mp-compliance-report-fwupd.png new file mode 100644 index 00000000..06f318ff Binary files /dev/null and b/reporting/compliance-report-fwupd/mp-compliance-report-fwupd.png differ diff --git a/security/conditional-installer/README.md b/security/conditional-installer/README.md new file mode 100644 index 00000000..1c9cc394 --- /dev/null +++ b/security/conditional-installer/README.md @@ -0,0 +1,25 @@ +This is an experimental module for both uninstalling and installing packages based on conditions (CFEngine class expressions). +Users can specify a list of packages which should be uninstalled by default, and some packages which should be installed under certain conditions. +With this logic, you can have a long list of packages which are generally not allowed, and then for some of them, specify the exact scenarios where they are allowed. +This module uses the system package manager (via packages promises) to both install and uninstall packages. + +**Example:** For security reasons, you want to uninstall `talk`, `samba` and `apache2` in your infrastructure. +On your webservers, which have the `webserver` CFEngine class, you want Apache to be installed. +In this module, you put `talk,samba,apache2` in the list of packages to uninstall, and in the list of packages to install you put `apache2` with the condition `webserver`. +The module will install `apache2` on your webservers and uninstall it everywhere else. +`talk` and `samba` will be uninstalled everywhere. +As always with CFEngine, if the state is already correct, if the packages are already installed / not installed, no actions will be performed. + +**Hint:** Package names are based on your system's package manager. +In the list of packages to uninstall, it is convenient to just name all the variants of the name, for example `httpd` for RHEL and `apache2` for Debian-based systems. +For the packages to install you have to be more careful; when package names are different use the platform as part of the condition and the correct package name for that platform. +For our example above, it could be install `apache2` with the condition `webserver&debian`, and another entry to install `httpd` with the condition `webserver&redhat`. + +**Note:** This module is experimental, and things might change. +Specifically the module input currently accepts strings with comma separated package names. +We might change this to lists of strings and also try out different ways to specify input. +Feel free to play with it and give us feedback, but maybe don't use it for important infrastructure, yet. + +**Warning:** If using the same packages in both lists of packages to install and uninstall, be careful with how you specify the condition. +In general, you want the condition to be very stable (not varying over time) - when the condition is true the package will be installed and when false, the package is uninstalled. +If you use time-based classes or other conditions which vary, you can end up in a situation where you install and uninstall the package over and over again. diff --git a/security/conditional-installer/main.cf b/security/conditional-installer/main.cf new file mode 100644 index 00000000..996f7e1b --- /dev/null +++ b/security/conditional-installer/main.cf @@ -0,0 +1,61 @@ +body file control +{ + namespace => "conditional_installer"; +} + +bundle agent main +{ + classes: + "enabled" + if => and( + isvariable(packages_to_install), isvariable(packages_to_uninstall) + ); + + vars: + # Mock data, should be based on input: + # "packages_to_uninstall" + # string => 'talk,talk-server,telnet,telnet-server'; + # + # "packages_to_install" + # data => '[ + # {"packages": "talk,talk-server", "condition": "any", "why": ""}, + # {"packages": "telnet", "condition": "telnet_test_machine", "why": ""} + # ]'; + enabled:: + # Determine packages to install: + "i" slist => getindices(packages_to_install); + + "_packages_to_install_lists[$(i)]" + slist => string_split( + "$(packages_to_install[$(i)][packages])", ",", 100 + ), + if => "$(packages_to_install[$(i)][condition])", + comment => "$(packages_to_install[$(i)][why])"; + + "_packages_to_install" slist => getvalues(_packages_to_install_lists); + + # Determine packages to uninstall: + "_packages_to_uninstall_unfiltered" + slist => string_split("$(packages_to_uninstall)", ",", 100); + + "_packages_to_uninstall" + slist => difference( + _packages_to_uninstall_unfiltered, _packages_to_install + ); + + packages: + enabled:: + "$(_packages_to_uninstall)" policy => "absent"; + "$(_packages_to_install)" policy => "present"; +} + +body file control +{ + namespace => "default"; +} + +bundle agent __main__ +{ + methods: + "conditional_installer:main"; +} diff --git a/security/cron-access/cron-access.cf b/security/cron-access/cron-access.cf index 4e8e3219..fc2a2b63 100644 --- a/security/cron-access/cron-access.cf +++ b/security/cron-access/cron-access.cf @@ -21,9 +21,11 @@ bundle agent cron_access "$(directories)" perms => mog("700", "root", "root"), if => fileexists("$(this.promiser)"); + "/etc/cron.allow" perms => mog("640", "root", "root"), if => fileexists("$(this.promiser)"); + "/etc/crontab" perms => mog("600", "root", "root"), if => fileexists("$(this.promiser)"); diff --git a/security/delete-home-dotrhosts/policy/main.cf b/security/delete-home-dotrhosts/policy/main.cf index d0500a50..915a75d4 100644 --- a/security/delete-home-dotrhosts/policy/main.cf +++ b/security/delete-home-dotrhosts/policy/main.cf @@ -1,6 +1,6 @@ body file control { - namespace => "delete_home_dotrhosts"; + namespace => "delete_home_dotrhosts"; } bundle agent main @@ -9,7 +9,6 @@ bundle agent main # @inventory ~/.rhosts list of ~.rhosts files found. # @inventory ~/.rhosts Exception The string defined that records the details about why a host has an exception for this policy. { - classes: "dotrhosts_management_disabled" or => { @@ -21,30 +20,29 @@ bundle agent main vars: dotrhosts_management_disabled:: - "exception" # Fill in exception reason if missing (class was used) + # Fill in exception reason if missing (class was used) + "exception" string => "Unknown reason", if => not(isvariable("delete_home_dotrhosts:main.exception")); !default:windows:: - # /home and /root are common default home dir roots, but it's not uncommon # to have other home dir roots - "home_dir_roots" slist => { "/home" }, meta => { "noreport" }, - if => not( isvariable( "home_dir_roots" )); + if => not(isvariable("home_dir_roots")); - # Find .rhosts files in the root of peoples home directories. + # Find .rhosts files in the root of peoples home directories. "_found[$(home_dir_roots)]" - slist => findfiles( "$(home_dir_roots)/*/.rhosts" ), - if => isdir( $(home_dir_roots) ); + slist => findfiles("$(home_dir_roots)/*/.rhosts"), + if => isdir($(home_dir_roots)); + "_found[/root]" slist => { "/root/.rhosts" }, - if => fileexists( "/root/.rhosts" ); + if => fileexists("/root/.rhosts"); - "_found" - slist => getvalues( _found ); + "_found" slist => getvalues(_found); # We inventory the found files so that we easily know where they are. # This way, even hosts with exceptions will report the inventory, but not @@ -60,16 +58,15 @@ bundle agent main "inv_exception" string => "$(exception)", - if => isvariable( "exception" ), + if => isvariable("exception"), meta => { "inventory", "attribute_name=~/.rhosts exception reason" }; files: !dotrhosts_management_disabled.!default:windows:: - "$(_found)" - delete => default:tidy; + "$(_found)" delete => default:tidy; reports: dotrhosts_management_disabled.(default:inform_mode|default:verbose_mode):: "Found $(with), but not removing because of exception: $(exception)" - with => join( ", ", _found ); + with => join(", ", _found); } diff --git a/security/delete-home-dotshosts/policy/main.cf b/security/delete-home-dotshosts/policy/main.cf index 1a836304..f4ce6393 100644 --- a/security/delete-home-dotshosts/policy/main.cf +++ b/security/delete-home-dotshosts/policy/main.cf @@ -1,6 +1,6 @@ body file control { - namespace => "delete_home_dotshosts"; + namespace => "delete_home_dotshosts"; } bundle agent main @@ -9,7 +9,6 @@ bundle agent main # @inventory ~/.shosts list of ~.shosts files found. # @inventory ~/.shosts Exception The string defined that records the details about why a host has an exception for this policy. { - classes: "dotshosts_management_disabled" or => { @@ -21,30 +20,29 @@ bundle agent main vars: dotshosts_management_disabled:: - "exception" # Fill in exception reason if missing (class was used) + # Fill in exception reason if missing (class was used) + "exception" string => "Unknown reason", if => not(isvariable("delete_home_dotshosts:main.exception")); !default:windows:: - # /home and /root are common default home dir roots, but it's not uncommon # to have other home dir roots - "home_dir_roots" slist => { "/home" }, meta => { "noreport" }, - if => not( isvariable( "home_dir_roots" )); + if => not(isvariable("home_dir_roots")); - # Find .shosts files in the root of peoples home directories. + # Find .shosts files in the root of peoples home directories. "_found[$(home_dir_roots)]" - slist => findfiles( "$(home_dir_roots)/*/.shosts" ), - if => isdir( $(home_dir_roots) ); + slist => findfiles("$(home_dir_roots)/*/.shosts"), + if => isdir($(home_dir_roots)); + "_found[/root]" slist => { "/root/.shosts" }, - if => fileexists( "/root/.shosts" ); + if => fileexists("/root/.shosts"); - "_found" - slist => getvalues( _found ); + "_found" slist => getvalues(_found); # We inventory the found files so that we easily know where they are. # This way, even hosts with exceptions will report the inventory, but not @@ -60,16 +58,15 @@ bundle agent main "inv_exception" string => "$(exception)", - if => isvariable( "exception" ), + if => isvariable("exception"), meta => { "inventory", "attribute_name=~/.shosts exception reason" }; files: !dotshosts_management_disabled.!default:windows:: - "$(_found)" - delete => default:tidy; + "$(_found)" delete => default:tidy; reports: dotshosts_management_disabled.(default:inform_mode|default:verbose_mode):: "Found $(with), but not removing because of exception: $(exception)" - with => join( ", ", _found ); + with => join(", ", _found); } diff --git a/security/dirtyfrag/README.md b/security/dirtyfrag/README.md new file mode 100644 index 00000000..5c529a7c --- /dev/null +++ b/security/dirtyfrag/README.md @@ -0,0 +1,190 @@ +Dirty Frag is a pair of kernel page-cache write vulnerabilities affecting Linux kernel modules that use nonlinear sk_buff (skb) fragments. An unprivileged local attacker with access to a network namespace can trigger out-of-bounds memory writes, potentially leading to privilege escalation. + +- **CVE-2026-43284** (xfrm-ESP/IPComp): Affects `esp4.ko`, `esp6.ko`, `ipcomp.ko`, and `ipcomp6.ko` modules when unprivileged user namespaces are enabled. Patched in stable kernel trees as of May 2026. +- **CVE-2026-43500** (RxRPC): Affects `rxrpc.ko` module. Patches available for some distros as of May 2026; mitigation via module blacklisting where unpatched. + +## Vulnerability conditions + +- **CVE-2026-43284**: Requires `esp4`, `esp6`, `ipcomp`, or `ipcomp6` kernel modules present AND `/proc/sys/kernel/unprivileged_userns_clone` set to `1` +- **CVE-2026-43500**: Requires `rxrpc` kernel module present (no additional prerequisites) + +## Inventory + +After adding this module you can view Dirty Frag vulnerability status in Mission Portal Inventory Report: + +[![Inventory showing Dirty Frag status](https://raw.githubusercontent.com/cfengine/modules/master/security/dirtyfrag/inventory-status.png)](https://raw.githubusercontent.com/cfengine/modules/master/security/dirtyfrag/inventory-status.png) + +- **Dirty Frag CVE-2026-43284 (xfrm-ESP) status**: + - `VULNERABLE (esp4, esp6 loaded)` -- vulnerable modules currently in memory (names vary by host) + - `VULNERABLE (modules on disk, none loaded)` -- modules present but not loaded; latent risk + - `PATCHED (kernel fix applied)` -- running kernel version includes the fix (auto-detected or admin-declared) + - `MITIGATED (blacklist in place)` -- modprobe blacklist active for esp4/esp6/ipcomp/ipcomp6 + - `MITIGATED (userns disabled)` -- `user.max_user_namespaces=0` sysctl active, blocking the exploit path without unloading IPsec + - `NOT AFFECTED` -- vulnerable modules not present on this host +- **Dirty Frag CVE-2026-43500 (RxRPC) status**: + - `VULNERABLE (rxrpc loaded)` -- module currently in memory + - `VULNERABLE (module on disk, not loaded)` -- module present but not loaded; latent risk + - `PATCHED (kernel fix applied)` -- running kernel version includes the fix (auto-detected or admin-declared) + - `MITIGATED (blacklist in place)` -- modprobe blacklist active + - `NOT AFFECTED` -- rxrpc module not present on this host + +## Mitigation + +Each CVE has an independent toggle and separate conf file: + +**CVE-2026-43284** (ESP/IPComp) -- `/etc/modprobe.d/dirtyfrag-esp.conf`: + +``` +# Dirty Frag CVE-2026-43284 mitigation: block xfrm-ESP and IPComp +install esp4 /bin/false +install esp6 /bin/false +install ipcomp /bin/false +install ipcomp6 /bin/false +``` + +**CVE-2026-43500** (RxRPC) -- `/etc/modprobe.d/dirtyfrag-rxrpc.conf`: + +``` +# Dirty Frag CVE-2026-43500 mitigation: block RxRPC +install rxrpc /bin/false +``` + +This prevents the vulnerable modules from loading. When mitigation is first applied, already-loaded modules are unloaded via `rmmod`. + +**CVE-2026-43284 alternative** (user namespaces) -- `/etc/sysctl.d/dirtyfrag-userns.conf`: + +``` +# Dirty Frag CVE-2026-43284 mitigation: disable unprivileged user namespaces +# Blocks ESP/IPComp exploit without disabling IPsec. +# WARNING: May affect rootless containers and sandboxed applications. +user.max_user_namespaces = 0 +``` + +This blocks the ESP/IPComp exploit path without blacklisting the modules, preserving IPsec functionality. Use this instead of `mitigate_esp` on hosts that require IPsec. Note: this does **not** mitigate CVE-2026-43500 (RxRPC) and may break rootless containers (Podman, Docker rootless), Flatpak, and browser sandboxes. Applied via `sysctl --system` on first write. + +All mitigations are **disabled by default** -- the module only reports status unless the corresponding CMDB variable (`dirtyfrag:main.mitigate_esp`, `dirtyfrag:main.mitigate_rxrpc`, or `dirtyfrag:main.mitigate_userns`) is set to `"true"`. See the table below for the full set of toggles. + +## Usage + +Add the policy to your inputs: + +``` +inputs "security/dirtyfrag/dirtyfrag.cf" +``` + +To enable mitigation, set one or both variables in your site's `def.json` (Augments): + +```json +{ + "variables": { + "dirtyfrag:main.mitigate_esp": { "value": "true" }, + "dirtyfrag:main.mitigate_rxrpc": { "value": "true" }, + "dirtyfrag:main.mitigate_userns": { "value": "true" }, + "dirtyfrag:main.esp_patched": { "value": "true" }, + "dirtyfrag:main.rxrpc_patched": { "value": "true" } + } +} +``` + +| Variable | What it does | Trade-off | +|----------|-------------|-----------| +| `mitigate_esp` | Blacklists esp4, esp6, ipcomp, ipcomp6 | Breaks IPsec | +| `mitigate_rxrpc` | Blacklists rxrpc | Breaks AFS/RxRPC | +| `mitigate_userns` | Sets `user.max_user_namespaces=0` | May break rootless containers/sandboxes | +| `esp_patched` | Declare CVE-2026-43284 as patched | Admin must verify kernel is actually patched | +| `rxrpc_patched` | Declare CVE-2026-43500 as patched | Admin must verify kernel is actually patched | + +Typical combinations: +- **Most hosts**: `mitigate_esp` + `mitigate_rxrpc` (full protection) +- **IPsec hosts**: `mitigate_userns` + `mitigate_rxrpc` (preserves IPsec) +- **Container hosts needing IPsec**: `mitigate_rxrpc` only (partial, accept ESP risk until patched kernel) + +Default behavior (variables unset) is status-only reporting. + +## Detection details + +The module checks for vulnerable modules in three ways: + +1. **On-disk `.ko` files** under `/lib/modules/$(kernel_version)/` +2. **Compressed variants** (`.ko.zst`, `.ko.xz`) on distros that compress modules +3. **Currently loaded modules** via `/sys/module/` entries + +For CVE-2026-43284, the module also checks whether unprivileged user namespaces are enabled (`/proc/sys/kernel/unprivileged_userns_clone`), since the exploit requires namespace access. + +## Kernel patch detection + +The module automatically detects whether the running kernel includes fixes for the Dirty Frag CVEs by comparing the kernel version (`uname -r`) against known-patched versions from distro security advisories. This data is maintained in `patched-kernels.json`, shipped alongside the policy. + +Currently tracked distros: + +| Distro | CVE-2026-43284 | CVE-2026-43500 | +|--------|---------------|---------------| +| RHEL/CentOS/Alma/Rocky 8 | 4.18.0-553.123.2 | 4.18.0-553.123.2 | +| RHEL/CentOS/Alma/Rocky 9 | 5.14.0-611.54.1 | 5.14.0-611.54.3 | +| RHEL/CentOS/Alma/Rocky 10 | 6.12.0-124.55.2 | 6.12.0-124.55.3 | +| Debian 11 (Bullseye) | 5.10.251-4 | 5.10.251-4 | +| Debian 12 (Bookworm) | 6.1.170-3 | 6.1.170-3 | +| Debian 13 (Trixie) | 6.12.86-1 | 6.12.86-1 | +| SLES 15 SP7 | 6.4.0-150700.53.45.1 | 6.4.0-150700.53.45.1 | + +When a patched kernel is detected, the status reports `PATCHED (kernel fix applied)` instead of `VULNERABLE`. The module uses `sort -V` (version sort from coreutils) to compare kernel versions. + +For distros not in the data file, or hosts running custom/backported kernels, set the admin override variables `esp_patched` and/or `rxrpc_patched` to `"true"` via augments. + +To update the patched kernel data, edit `patched-kernels.json` and redeploy. The data file is intentionally separate from the policy so it can be updated independently. + +## Adding exceptions + +To exclude specific hosts from mitigation, use conditional augments to override them to a value other than `"true"`. + +## Mission Portal — operator reference + +The module is structured to give Mission Portal operators four things: inventory columns for at-a-glance state, a filterable mitigation-method enum, queryable classes for targeting, and CVE-linked promise stakeholders for audit traceability. + +### Inventory columns + +| Attribute name | Values | Filterable as | +|---|---|---| +| `Dirty Frag CVE-2026-43284 (xfrm-ESP) status` | `VULNERABLE (...)`, `PATCHED (kernel fix applied)`, `MITIGATED (blacklist in place)`, `MITIGATED (userns disabled)`, `NOT AFFECTED` | starts-with regex (`^VULNERABLE`, `^PATCHED`, `^MITIGATED`) | +| `Dirty Frag CVE-2026-43500 (RxRPC) status` | same shape, no userns option | same | +| `Dirty Frag CVE-2026-43284 mitigation method` | `kernel-patch`, `modprobe`, `userns`, `admin-override`, `none`, `not-applicable` | exact match | +| `Dirty Frag CVE-2026-43500 mitigation method` | `kernel-patch`, `modprobe`, `admin-override`, `none`, `not-applicable` | exact match | + +The detailed status strings are for humans; the method enums are for dashboards and filters. + +### Queryable classes (collected as `report`, not in default inventory columns) + +| Class | Meaning | Use case | +|---|---|---| +| `dirtyfrag_vulnerable` | Any tracked CVE is unmitigated on this host | Targeting: "patch these now" | +| `dirtyfrag_esp_mitigated` | ESP mitigation path is active (any of blacklist / userns / patched) | Audit: confirm coverage | +| `dirtyfrag_rxrpc_mitigated` | RxRPC mitigation path is active | Same | +| `dirtyfrag_esp_needs_mitigation` | ESP exposure exists and no mitigation in place | Targeting fragments | +| `dirtyfrag_rxrpc_needs_mitigation` | Same for RxRPC | Same | +| `dirtyfrag_esp_present`, `dirtyfrag_rxrpc_present` | Module is loadable or loaded | Scoping | + +Tagged with `meta => { "report" }` so cf-hub collects them for queries but they don't add columns to the default inventory view. + +### Recommended alerts + +Configure in Mission Portal → **Alerts**. The module ships no alerts itself; operators wire conditions appropriate to their fleet's risk tolerance: + +- **Inventory condition** — alert when `Dirty Frag CVE-2026-43284 (xfrm-ESP) status` matches regex `^VULNERABLE`. Catches new unmitigated hosts as they join the fleet or as kernels regress. +- **Inventory condition** — alert when `Dirty Frag CVE-2026-43284 mitigation method` equals `userns` on more than N hosts. The userns path is fragile (breaks rootless containers); knowing how many hosts depend on it informs upgrade prioritization. +- **Policy condition** — alert on any **promises not kept** for handles `dirtyfrag_esp_modprobe_blacklist`, `dirtyfrag_rxrpc_modprobe_blacklist`, `dirtyfrag_esp_modprobe_unload`, `dirtyfrag_rxrpc_modprobe_unload`, `dirtyfrag_userns_sysctl_conf`, `dirtyfrag_userns_sysctl_reapply`. These are the file/command promises that enforce mitigation; a failure means the conf wasn't written or the module wasn't unloaded. + +### Promise handles (for report filtering) + +| Handle | What | When it fails | +|---|---|---| +| `dirtyfrag_esp_modprobe_blacklist` | Writes `/etc/modprobe.d/dirtyfrag-esp.conf` | Filesystem error, conflicting writes | +| `dirtyfrag_rxrpc_modprobe_blacklist` | Writes `/etc/modprobe.d/dirtyfrag-rxrpc.conf` | Same | +| `dirtyfrag_userns_sysctl_conf` | Writes `/etc/sysctl.d/dirtyfrag-userns.conf` | Same | +| `dirtyfrag_esp_modprobe_unload` | `modprobe -r` on currently-loaded ESP/IPComp modules | Module busy (existing IPsec tunnel) | +| `dirtyfrag_rxrpc_modprobe_unload` | `modprobe -r rxrpc` | Module busy | +| `dirtyfrag_userns_sysctl_reapply` | `sysctl --system` to load the userns conf | sysctl conf rejected | + +### Compliance traceability + +Every file and command promise carries a **promisee arrow** linking to its CVE: `"${path}" -> { "CVE-2026-43284" }` and `"${path}" -> { "CVE-2026-43500" }`. In Mission Portal's promise detail view, this surfaces as the stakeholder / linked identifier. Searching the audit history for `CVE-2026-43284` returns every policy artifact addressing it. + diff --git a/security/dirtyfrag/dirtyfrag.cf b/security/dirtyfrag/dirtyfrag.cf new file mode 100644 index 00000000..80a4f29e --- /dev/null +++ b/security/dirtyfrag/dirtyfrag.cf @@ -0,0 +1,434 @@ +body file control +{ + namespace => "dirtyfrag"; +} + +bundle agent main +# @brief Detects Dirty Frag (CVE-2026-43284, CVE-2026-43500) vulnerability +# and applies CMDB-toggleable mitigations. CMDB variables, mitigation +# trade-offs and inventory states are documented in README.md. +# +# @inventory Dirty Frag CVE-2026-43284 (xfrm-ESP) status - Vulnerability/mitigation state for the xfrm-ESP/IPComp CVE. +# @inventory Dirty Frag CVE-2026-43500 (RxRPC) status - Vulnerability/mitigation state for the RxRPC CVE. +# @inventory Dirty Frag CVE-2026-43284 mitigation method - Which mitigation strategy is currently active for ESP (kernel-patch, modprobe, userns, admin-override, none, not-applicable). +# @inventory Dirty Frag CVE-2026-43500 mitigation method - Which mitigation strategy is currently active for RxRPC (kernel-patch, modprobe, admin-override, none, not-applicable). +# @class dirtyfrag_vulnerable - true if any tracked CVE is unmitigated on this host (queryable; not in default inventory column set). +# @class dirtyfrag_esp_mitigated - true if any ESP mitigation path is active on this host. +# @class dirtyfrag_rxrpc_mitigated - true if any RxRPC mitigation path is active on this host. +{ + vars: + # --- Patched-kernel data lookup --------------------------------- + # Minimum patched kernel package versions per distro live in + # patched-kernels.json next to this policy. The data file is + # walked here; comparison happens in the classes:: block below. + "_data_file" string => "$(this.promise_dirname)/patched-kernels.json"; + + "_data" + data => readjson("${_data_file}"), + if => fileexists("${_data_file}"); + + # Indexing goes inside ${...}; see lessons-learned/data-indexing.md. + "_entries_idx" + slist => getindices("_data[entries]"), + if => isvariable("_data"); + + "_os_id" + string => "$(default:sys.os_release[ID])", + if => isvariable("default:sys.os_release[ID]"); + + "_os_ver" + string => "$(default:sys.os_release[VERSION_ID])", + if => isvariable("default:sys.os_release[VERSION_ID]"); + + # Find matching entry: the entry whose id_match and version_match + # both match this host's os-release ID and VERSION_ID. + "_matched_idx" + string => "${_entries_idx}", + if => and( + regcmp("${_data[entries][${_entries_idx}][id_match]}", "${_os_id}"), + regcmp( + "${_data[entries][${_entries_idx}][version_match]}", "${_os_ver}" + ) + ); + + "_esp_patched_ver" + string => "${_data[entries][${_matched_idx}][cve_2026_43284]}", + if => isvariable("_matched_idx"); + + "_rxrpc_patched_ver" + string => "${_data[entries][${_matched_idx}][cve_2026_43500]}", + if => isvariable("_matched_idx"); + + # --- Installed kernel package version -------------------------- + # RHEL family: sys.release is the package NEVR -- compare directly. + # Debian/Ubuntu: sys.release is an ABI version; the package version + # lives in package metadata. packagesmatching() reads the + # package_inventory cache populated by the standard masterfiles + # apt_get inventory. See lessons-learned/packagesmatching.md. + # Other: fall back to sys.release as best-effort. + "_kernel_pkgs" + data => packagesmatching( + "linux-image-$(default:sys.release)", ".*", ".*", "apt_get" + ), + if => regcmp("debian|ubuntu", "$(default:sys.os_release[ID])"); + + "_kernel_pkg_ver" + string => "$(_kernel_pkgs[0][version])", + if => isvariable("_kernel_pkgs[0][version]"); + + "_kernel_pkg_ver" + string => "$(default:sys.release)", + if => not(regcmp("debian|ubuntu", "$(default:sys.os_release[ID])")); + + # --- Mitigation-conf paths and contents ----------------------- + "_esp_conf_path" string => "/etc/modprobe.d/dirtyfrag-esp.conf"; + "_rxrpc_conf_path" string => "/etc/modprobe.d/dirtyfrag-rxrpc.conf"; + "_userns_conf_path" string => "/etc/sysctl.d/dirtyfrag-userns.conf"; + + "_esp_conf_content" + string => concat( + "# Dirty Frag CVE-2026-43284 mitigation: block xfrm-ESP and IPComp$(const.n)", + "install esp4 /bin/false$(const.n)", + "install esp6 /bin/false$(const.n)", + "install ipcomp /bin/false$(const.n)", + "install ipcomp6 /bin/false$(const.n)" + ); + + "_rxrpc_conf_content" + string => concat( + "# Dirty Frag CVE-2026-43500 mitigation: block RxRPC$(const.n)", + "install rxrpc /bin/false$(const.n)" + ); + + "_userns_conf_content" + string => concat( + "# Dirty Frag CVE-2026-43284 mitigation: disable unprivileged user namespaces$(const.n)", + "# Blocks ESP/IPComp exploit without disabling IPsec.$(const.n)", + "# WARNING: May affect rootless containers and sandboxed applications.$(const.n)", + "user.max_user_namespaces = 0$(const.n)" + ); + + # --- Module files on disk ------------------------------------- + # Single findfiles glob covers .ko, .ko.xz, .ko.zst and any future + # compression scheme. Empty list means the modules aren't shipped + # for this kernel build. + "_esp_module_files" + slist => findfiles( + "/lib/modules/$(default:sys.release)/kernel/net/ipv4/esp4.ko*", + "/lib/modules/$(default:sys.release)/kernel/net/ipv6/esp6.ko*", + "/lib/modules/$(default:sys.release)/kernel/net/ipv4/ipcomp.ko*", + "/lib/modules/$(default:sys.release)/kernel/net/ipv6/ipcomp6.ko*" + ); + + "_rxrpc_module_files" + slist => findfiles( + "/lib/modules/$(default:sys.release)/kernel/net/rxrpc/rxrpc.ko*" + ); + + # --- Unprivileged user namespace setting ---------------------- + # sysctlvalue works across RHEL/SLES/Debian regardless of the + # /proc/sys/kernel/unprivileged_userns_clone vs user.max_user_namespaces + # path differences. + "_max_userns" string => sysctlvalue("user.max_user_namespaces"); + + # --- Loaded-module names for the VULNERABLE status string ----- + # Each slot is the module name when loaded, "" otherwise. + # filter(".+", ...) drops the empties; join glues with ", ". + "_esp_loaded_list" + slist => { + ifelse("_esp4_loaded", "esp4", ""), + ifelse("_esp6_loaded", "esp6", ""), + ifelse("_ipcomp_loaded", "ipcomp", ""), + ifelse("_ipcomp6_loaded", "ipcomp6", ""), + }; + + "_esp_loaded_names" + string => join( + ", ", filter(".+", "_esp_loaded_list", "false", "false", "10") + ); + + # --- Status strings ------------------------------------------- + "_esp_status" + string => ifelse( + "dirtyfrag_esp_needs_mitigation._esp_any_loaded", + "VULNERABLE ($(_esp_loaded_names) loaded)", + "dirtyfrag_esp_needs_mitigation", + "VULNERABLE (modules on disk, none loaded)", + "_esp_kernel_patched|_esp_admin_patched", + "PATCHED (kernel fix applied)", + "dirtyfrag_esp_present._esp_mitigated", + "MITIGATED (blacklist in place)", + "dirtyfrag_esp_present._userns_conf_exists", + "MITIGATED (userns disabled)", + "NOT AFFECTED" + ); + + "_rxrpc_status" + string => ifelse( + "dirtyfrag_rxrpc_needs_mitigation._rxrpc_loaded", + "VULNERABLE (rxrpc loaded)", + "dirtyfrag_rxrpc_needs_mitigation", + "VULNERABLE (module on disk, not loaded)", + "_rxrpc_kernel_patched|_rxrpc_admin_patched", + "PATCHED (kernel fix applied)", + "dirtyfrag_rxrpc_present._rxrpc_mitigated", + "MITIGATED (blacklist in place)", + "NOT AFFECTED" + ); + + # --- Per-CVE mitigation method (one-word enum) ----------------- + # Single keyword per CVE so MP can filter "show me all hosts where + # mitigation method is `userns`" -- much cleaner than regex-matching + # the long _esp_status string. ifelse takes first-match order. + "_esp_mitigation_method" + string => ifelse( + "dirtyfrag_esp_needs_mitigation", + "none", + "_esp_kernel_patched", + "kernel-patch", + "_esp_admin_patched", + "admin-override", + "_esp_conf_exists", + "modprobe", + "_userns_conf_exists", + "userns", + "not-applicable" + ); + + "_rxrpc_mitigation_method" + string => ifelse( + "dirtyfrag_rxrpc_needs_mitigation", + "none", + "_rxrpc_kernel_patched", + "kernel-patch", + "_rxrpc_admin_patched", + "admin-override", + "_rxrpc_conf_exists", + "modprobe", + "not-applicable" + ); + + # --- Inventory output for Mission Portal ---------------------- + # The CVE id and module family live in attribute_name (the column + # header); the value is just the state so it's easy to filter on. + "inventory_dirtyfrag_esp" + string => "$(_esp_status)", + meta => { + "inventory", + "attribute_name=Dirty Frag CVE-2026-43284 (xfrm-ESP) status", + }, + comment => "CVE-2026-43284 xfrm-ESP mitigation status"; + + "inventory_dirtyfrag_rxrpc" + string => "$(_rxrpc_status)", + meta => { + "inventory", "attribute_name=Dirty Frag CVE-2026-43500 (RxRPC) status", + }, + comment => "CVE-2026-43500 RxRPC mitigation status"; + + "inventory_dirtyfrag_esp_method" + string => "$(_esp_mitigation_method)", + meta => { + "inventory", + "attribute_name=Dirty Frag CVE-2026-43284 mitigation method", + }, + comment => "Active mitigation strategy for CVE-2026-43284 (filterable enum)"; + + "inventory_dirtyfrag_rxrpc_method" + string => "$(_rxrpc_mitigation_method)", + meta => { + "inventory", + "attribute_name=Dirty Frag CVE-2026-43500 mitigation method", + }, + comment => "Active mitigation strategy for CVE-2026-43500 (filterable enum)"; + + classes: + # --- CMDB toggles --------------------------------------------- + "_mitigate_esp" expression => strcmp("true", "$(mitigate_esp)"); + "_mitigate_rxrpc" expression => strcmp("true", "$(mitigate_rxrpc)"); + "_mitigate_userns" expression => strcmp("true", "$(mitigate_userns)"); + + # --- Admin override: manually declare host as patched --------- + "_esp_admin_patched" expression => strcmp("true", "$(esp_patched)"); + "_rxrpc_admin_patched" expression => strcmp("true", "$(rxrpc_patched)"); + + # --- Kernel-patch comparison ---------------------------------- + # sort -V -C exits 0 iff stdin is already version-sorted, so the + # pipeline below answers "is installed >= patched?" in one fork. + # This keeps useshell despite the noshell preference elsewhere: + # CFEngine has no semantic-version comparison primitive, sort -V + # needs stdin (no single-arg form), and the noshell alternatives + # (tempfile + commands:, or a bundled wrapper script) are heavier + # for no real safety gain on a static, internally-built string. + # See lessons-learned/version-compare-shell-exception.md. + "_patch_data_matched" expression => isvariable("_matched_idx"); + + "_esp_kernel_patched" + expression => returnszero( + "printf '%s\\n%s\\n' '$(_esp_patched_ver)' '$(_kernel_pkg_ver)' | sort -V -C", + "useshell" + ), + if => and(isvariable("_esp_patched_ver"), isvariable("_kernel_pkg_ver")); + + "_rxrpc_kernel_patched" + expression => returnszero( + "printf '%s\\n%s\\n' '$(_rxrpc_patched_ver)' '$(_kernel_pkg_ver)' | sort -V -C", + "useshell" + ), + if => and( + isvariable("_rxrpc_patched_ver"), isvariable("_kernel_pkg_ver") + ); + + # --- Unprivileged user namespace enabled? --------------------- + "_userns_disabled" expression => strcmp("0", "${_max_userns}"); + "_userns_enabled" not => "_userns_disabled"; + + # --- xfrm-ESP/IPComp modules ---------------------------------- + # On-disk presence collapses to: did findfiles return anything? + # some(".+", slist) is true when the list has any non-empty element. + "_esp_files_present" expression => some(".+", "_esp_module_files"); + "_esp4_loaded" expression => isdir("/sys/module/esp4"); + "_esp6_loaded" expression => isdir("/sys/module/esp6"); + "_ipcomp_loaded" expression => isdir("/sys/module/ipcomp"); + "_ipcomp6_loaded" expression => isdir("/sys/module/ipcomp6"); + + "_esp_any_loaded" + or => { + "_esp4_loaded", "_esp6_loaded", "_ipcomp_loaded", "_ipcomp6_loaded" + }; + + "dirtyfrag_esp_present" or => { "_esp_files_present", "_esp_any_loaded" }; + + # --- RxRPC module --------------------------------------------- + "_rxrpc_files_present" expression => some(".+", "_rxrpc_module_files"); + "_rxrpc_loaded" expression => isdir("/sys/module/rxrpc"); + "dirtyfrag_rxrpc_present" or => { "_rxrpc_files_present", "_rxrpc_loaded" }; + + # --- Mitigation conf files in place? -------------------------- + "_esp_conf_exists" expression => fileexists("${_esp_conf_path}"); + "_rxrpc_conf_exists" expression => fileexists("${_rxrpc_conf_path}"); + "_userns_conf_exists" expression => fileexists("${_userns_conf_path}"); + + # ESP is mitigated by modprobe blacklist, patched kernel, or + # admin override. Userns mitigation is handled in the + # vulnerability condition (see dirtyfrag_esp_needs_mitigation). + "_esp_mitigated" + or => { "_esp_conf_exists", "_esp_kernel_patched", "_esp_admin_patched" }; + + # RxRPC is mitigated by the modprobe blacklist, patched kernel, + # or admin override. + "_rxrpc_mitigated" + or => { + "_rxrpc_conf_exists", "_rxrpc_kernel_patched", "_rxrpc_admin_patched", + }; + + # --- Per-CVE vulnerability checks ----------------------------- + # ESP is vulnerable if modules are present, not mitigated, + # AND userns mitigation conf is not in place. Userns conf is + # not in _esp_mitigated (which tracks blacklist/mitigation + # confs); it's tracked here so disabling userns blocks the + # exploit vector directly. + "dirtyfrag_esp_needs_mitigation" + and => { + "dirtyfrag_esp_present", "!_esp_mitigated", "!_userns_conf_exists" + }, + meta => { "report" }, + scope => "namespace"; + + "dirtyfrag_rxrpc_needs_mitigation" + and => { "dirtyfrag_rxrpc_present", "!_rxrpc_mitigated" }, + meta => { "report" }, + scope => "namespace"; + + # --- Composite roll-ups for targeting and querying ------------ + # Tagged `report` (not `inventory`) so cf-hub collects them but they + # don't clutter the default Mission Portal inventory columns. Use + # for class filters in reports, alerts, and CMDB-style targeting. + "dirtyfrag_vulnerable" + or => { + "dirtyfrag_esp_needs_mitigation", "dirtyfrag_rxrpc_needs_mitigation" + }, + meta => { "report" }, + scope => "namespace"; + + "dirtyfrag_esp_mitigated" + or => { "_esp_mitigated", "_userns_conf_exists" }, + meta => { "report" }, + scope => "namespace"; + + "dirtyfrag_rxrpc_mitigated" + or => { "_rxrpc_mitigated" }, + meta => { "report" }, + scope => "namespace"; + + files: + # CVE refs go in the promisee slot (-> { ... }); Mission Portal + # surfaces this as the "stakeholder / linked identifier" on the + # promise detail page so operators can search "CVE-2026-43284" + # and find every policy artifact addressing it. + # handle gives each promise a stable, queryable name in reports. + _mitigate_esp:: + "${_esp_conf_path}" -> { "CVE-2026-43284" } + handle => "dirtyfrag_esp_modprobe_blacklist", + create => "true", + content => "${_esp_conf_content}", + comment => "Blacklist xfrm-ESP/IPComp modules to mitigate Dirty Frag CVE-2026-43284"; + + _mitigate_rxrpc:: + "${_rxrpc_conf_path}" -> { "CVE-2026-43500" } + handle => "dirtyfrag_rxrpc_modprobe_blacklist", + create => "true", + content => "${_rxrpc_conf_content}", + comment => "Blacklist RxRPC module to mitigate Dirty Frag CVE-2026-43500"; + + _mitigate_userns:: + "${_userns_conf_path}" -> { "CVE-2026-43284" } + handle => "dirtyfrag_userns_sysctl_conf", + create => "true", + content => "${_userns_conf_content}", + comment => "Disable unprivileged user namespaces to block CVE-2026-43284 ESP exploit path"; + + commands: + # Key off "module is loaded + mitigation desired", not "conf was + # just written". The previous _conf_repaired guard missed the + # case where the conf file exists from a prior run and the module + # got loaded since -- the agent would never re-run the unload. + # modprobe -r (vs rmmod) tolerates "some of the named modules + # aren't loaded", which simplifies the per-module gating to a + # single class. + _mitigate_esp._esp_any_loaded:: + "/sbin/modprobe" -> { "CVE-2026-43284" } + handle => "dirtyfrag_esp_modprobe_unload", + arglist => { "-r", "esp4", "esp6", "ipcomp", "ipcomp6" }, + comment => "Unload ESP/IPComp modules while ESP mitigation is active"; + + _mitigate_rxrpc._rxrpc_loaded:: + "/sbin/modprobe" -> { "CVE-2026-43500" } + handle => "dirtyfrag_rxrpc_modprobe_unload", + arglist => { "-r", "rxrpc" }, + comment => "Unload RxRPC module while RxRPC mitigation is active"; + + # sysctl --system is idempotent; re-running it when the conf says + # 0 but the live value isn't 0 picks up cases where the conf was + # written but never applied (e.g. between writing and the next + # boot). + _mitigate_userns._userns_enabled:: + "/sbin/sysctl" -> { "CVE-2026-43284" } + handle => "dirtyfrag_userns_sysctl_reapply", + arglist => { "--system" }, + comment => "Re-apply user.max_user_namespaces=0 while userns is enabled"; + + reports: + inform_mode._patch_data_matched:: + "Dirty Frag: matched distro $(_os_id) $(_os_ver) (entry $(_matched_idx)); kernel package version=$(_kernel_pkg_ver)"; + + inform_mode._esp_kernel_patched:: + "Dirty Frag CVE-2026-43284: kernel $(_kernel_pkg_ver) >= $(_esp_patched_ver) (PATCHED)"; + + inform_mode._rxrpc_kernel_patched:: + "Dirty Frag CVE-2026-43500: kernel $(_kernel_pkg_ver) >= $(_rxrpc_patched_ver) (PATCHED)"; + + inform_mode:: + "Dirty Frag CVE-2026-43284 (xfrm-ESP/IPComp): $(_esp_status)"; + "Dirty Frag CVE-2026-43500 (RxRPC): $(_rxrpc_status)"; +} diff --git a/security/dirtyfrag/inventory-status.png b/security/dirtyfrag/inventory-status.png new file mode 100644 index 00000000..9bb81650 Binary files /dev/null and b/security/dirtyfrag/inventory-status.png differ diff --git a/security/dirtyfrag/patched-kernels.json b/security/dirtyfrag/patched-kernels.json new file mode 100644 index 00000000..928be019 --- /dev/null +++ b/security/dirtyfrag/patched-kernels.json @@ -0,0 +1,87 @@ +{ + "_meta": { + "description": "Minimum patched kernel package versions for Dirty Frag CVEs. Keyed by distro match (os_release ID + major version). The policy matches the running host against these entries and uses sort -V to compare the installed kernel package version against the minimum patched version.", + "updated": "2026-05-15", + "sources": [ + "https://access.redhat.com/security/vulnerabilities/RHSB-2026-003", + "https://security-tracker.debian.org/tracker/CVE-2026-43284", + "https://security-tracker.debian.org/tracker/CVE-2026-43500", + "https://ubuntu.com/security/CVE-2026-43500", + "https://almalinux.org/blog/2026-05-07-dirty-frag/", + "https://explore.alas.aws.amazon.com/CVE-2026-43284.html" + ], + "placeholder_note": "TBD-pending-USN sorts higher than any numeric version under sort -V, so the policy will report VULNERABLE until the entry is updated with the real fix version. Admins on hosts known to be patched can set dirtyfrag:main.esp_patched / .rxrpc_patched in site augments to override." + }, + "entries": [ + { + "label": "RHEL/CentOS/Alma/Rocky/OL 7 (ELS)", + "id_match": "^(rhel|centos|rocky|almalinux|ol)$", + "version_match": "^7(\\..*|$)", + "cve_2026_43284": "TBD-pending-USN", + "cve_2026_43500": "TBD-pending-USN" + }, + { + "label": "RHEL/CentOS/Alma/Rocky/OL 8", + "id_match": "^(rhel|centos|rocky|almalinux|ol)$", + "version_match": "^8(\\..*|$)", + "cve_2026_43284": "4.18.0-553.123.2", + "cve_2026_43500": "4.18.0-553.123.2" + }, + { + "label": "RHEL/CentOS/Alma/Rocky/OL 9", + "id_match": "^(rhel|centos|rocky|almalinux|ol)$", + "version_match": "^9(\\..*|$)", + "cve_2026_43284": "5.14.0-611.54.1", + "cve_2026_43500": "5.14.0-611.54.3" + }, + { + "label": "RHEL/CentOS/Alma/Rocky/OL 10", + "id_match": "^(rhel|centos|rocky|almalinux|ol)$", + "version_match": "^10(\\..*|$)", + "cve_2026_43284": "6.12.0-124.55.2", + "cve_2026_43500": "6.12.0-124.55.3" + }, + { + "label": "Debian 11 (Bullseye)", + "id_match": "^debian$", + "version_match": "^11(\\..*|$)", + "cve_2026_43284": "5.10.251-4", + "cve_2026_43500": "5.10.251-4" + }, + { + "label": "Debian 12 (Bookworm)", + "id_match": "^debian$", + "version_match": "^12(\\..*|$)", + "cve_2026_43284": "6.1.170-3", + "cve_2026_43500": "6.1.170-3" + }, + { + "label": "Debian 13 (Trixie)", + "id_match": "^debian$", + "version_match": "^13(\\..*|$)", + "cve_2026_43284": "6.12.86-1", + "cve_2026_43500": "6.12.86-1" + }, + { + "label": "Ubuntu 20.04 LTS (focal, ESM)", + "id_match": "^ubuntu$", + "version_match": "^20\\.04", + "cve_2026_43284": "TBD-pending-USN", + "cve_2026_43500": "TBD-pending-USN" + }, + { + "label": "Ubuntu 22.04 LTS (jammy)", + "id_match": "^ubuntu$", + "version_match": "^22\\.04", + "cve_2026_43284": "TBD-pending-USN", + "cve_2026_43500": "TBD-pending-USN" + }, + { + "label": "Ubuntu 24.04 LTS (noble)", + "id_match": "^ubuntu$", + "version_match": "^24\\.04", + "cve_2026_43284": "TBD-pending-USN", + "cve_2026_43500": "TBD-pending-USN" + } + ] +} diff --git a/security/enable-aslr/enable-aslr.cf b/security/enable-aslr/enable-aslr.cf index ef7a84a8..7acdbb3f 100644 --- a/security/enable-aslr/enable-aslr.cf +++ b/security/enable-aslr/enable-aslr.cf @@ -13,21 +13,23 @@ bundle agent enable_aslr "aslr_mode[0]" string => "Disabled"; "aslr_mode[1]" string => "Conservative Randomization"; "aslr_mode[2]" string => "Full Randomization"; - "aslr_proc_path" string => "/proc/sys/kernel/randomize_va_space"; "aslr_conf_path" string => "/etc/sysctl.d/60-enable-aslr.conf"; "aslr_value" string => readfile("${aslr_proc_path}"); - "randomize_va_space_inventory" string => "${aslr_mode[${aslr_value}]} (${aslr_value})", - meta => { "inventory", "attribute_name=Address space layout randomization (ASLR)" }, + + "randomize_va_space_inventory" + string => "${aslr_mode[${aslr_value}]} (${aslr_value})", + meta => { + "inventory", + "attribute_name=Address space layout randomization (ASLR)", + }, comment => "Report on Address space layout randomization (ASLR) mode"; classes: "aslr_enabled" expression => strcmp("${aslr_value}", "2"); + "aslr_management_disabled" - or => { - "exception_enable_aslr", - "data:exception_enable_aslr", - }; + or => { "exception_enable_aslr", "data:exception_enable_aslr" }; files: !aslr_management_disabled.(linux.!aslr_enabled):: @@ -37,6 +39,5 @@ bundle agent enable_aslr commands: !aslr_management_disabled.(linux.!aslr_enabled):: - "sysctl --load ${aslr_conf_path}" - contain => in_shell; + "sysctl --load ${aslr_conf_path}" contain => in_shell; } diff --git a/security/etc-issue-access/etc-issue-access.cf b/security/etc-issue-access/etc-issue-access.cf index 83c463f0..670c445c 100644 --- a/security/etc-issue-access/etc-issue-access.cf +++ b/security/etc-issue-access/etc-issue-access.cf @@ -8,7 +8,7 @@ bundle agent etc_issue_access # https://static.open-scap.org/ssg-guides/ssg-rhel7-guide-anssi_nt28_minimal.html { files: - "/etc/issue" -> { "CCE-83717-9", "CCE-83707-0", "CCE-83347-5"} + "/etc/issue" -> { "CCE-83717-9", "CCE-83707-0", "CCE-83347-5" } perms => mog("644", "root", "root"), if => fileexists("$(this.promiser)"); } diff --git a/security/etc-motd-access/etc-motd-access.cf b/security/etc-motd-access/etc-motd-access.cf index 36627b4c..3f0f7ec0 100644 --- a/security/etc-motd-access/etc-motd-access.cf +++ b/security/etc-motd-access/etc-motd-access.cf @@ -8,7 +8,7 @@ bundle agent etc_motd_access # https://static.open-scap.org/ssg-guides/ssg-rhel7-guide-anssi_nt28_minimal.html { files: - "/etc/motd" -> {"CCE-83737-7", "CCE-83727-8", "CCE-83337-6"} + "/etc/motd" -> { "CCE-83737-7", "CCE-83727-8", "CCE-83337-6" } perms => mog("644", "root", "root"), if => fileexists("$(this.promiser)"); } diff --git a/security/install-aide/install-aide.cf b/security/install-aide/install-aide.cf index 2c3ec472..8871c6e4 100644 --- a/security/install-aide/install-aide.cf +++ b/security/install-aide/install-aide.cf @@ -7,16 +7,19 @@ bundle agent install_aide { vars: redhat|suse:: - "pkg_name" slist => { "aide" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "aide" }; + debian:: - "pkg_name" slist => { "aide" }; # name in apt + # Name in apt: + "pkg_name" slist => { "aide" }; packages: - "${pkg_name}" -> { "CCE-27096-7" } - policy => "present", - if => isvariable("pkg_name"); + "${pkg_name}" -> { "CCE-27096-7" } + policy => "present", + if => isvariable("pkg_name"); reports: - "warning: aide package name not known for this platform" - unless => isvariable("pkg_name"); + "warning: aide package name not known for this platform" + unless => isvariable("pkg_name"); } diff --git a/security/inventory-selinux-modules/README.org b/security/inventory-selinux-modules/README.org new file mode 100644 index 00000000..aa72e9aa --- /dev/null +++ b/security/inventory-selinux-modules/README.org @@ -0,0 +1,15 @@ +This module inventories the SELinux Modules that are installed. + +* Inventory +- SELinux Modules Enabled :: SELinux Modules that are installed and not disabled. +- SELinux Modules Disabled :: SELinux Modules that are installed and disabled. + +[[https://raw.githubusercontent.com/cfengine/modules/master/security/inventory-selinux-modules/inventory-selinux-modules.png]] + +* Configuration +** Variables +- =inventory_selinux:semodule_list_modules.hours_cached= :: Number of hours to cache the output from ~semodule --list-modules=full~. + +* Requirements + +The ~semodule~ command is required for inventory to be reported. diff --git a/security/inventory-selinux-modules/inventory-selinux-modules.png b/security/inventory-selinux-modules/inventory-selinux-modules.png new file mode 100644 index 00000000..fe3e424f Binary files /dev/null and b/security/inventory-selinux-modules/inventory-selinux-modules.png differ diff --git a/security/inventory-selinux-modules/main.cf b/security/inventory-selinux-modules/main.cf new file mode 100644 index 00000000..caad3fbc --- /dev/null +++ b/security/inventory-selinux-modules/main.cf @@ -0,0 +1,76 @@ +body file control +{ + namespace => "inventory_selinux"; +} + +bundle agent semodule_list_modules +{ + classes: + "_$(this.namespace)_$(this.bundle)_supported_platform" + if => isexecutable("$(path[semodule])"); + + vars: + "path[semodule]" + string => ifelse( + isexecutable("/sbin/semodule"), + "/sbin/semodule", + isexecutable("/usr/sbin/semodule"), + "/usr/sbin/semodule", + "" + ), + meta => { "paths.cf" }; + + _inventory_selinux_semodule_list_modules_supported_platform:: + "hours_cached" + string => "1", + if => not(isvariable(hours_cached)); + + "cache_file" + string => "$(sys.statedir)/$(this.namespace)_$(this.bundle).txt"; + + "cache_file_age" + string => filestat($(cache_file), mtime), + if => fileexists("$(cache_file)"); + + # Example $(cache_file) content + # 400 cfengine-enterprise pp + # 200 container pp + # 100 wine pp disabled + # 100 wireshark pp + "d" + data => data_readstringarrayidx("$(cache_file)", "", "\s+", inf, inf), + if => fileexists($(cache_file)); + + "i" slist => getindices("d"); + + # Inventory enabled and disabled selinux modules + "enabled[$(i)]" + string => "$(d[$(i)][1])", + if => strcmp("$(d[$(i)][3])", ""), + meta => { "inventory", "attribute_name=SELinux Modules Enabled" }; + + "disabled[$(i)]" + string => "$(d[$(i)][1])", + if => strcmp("$(d[$(i)][3])", "disabled"), + meta => { "inventory", "attribute_name=SELinux Modules Disabled" }; + + files: + _inventory_selinux_semodule_list_modules_supported_platform:: + # Delete the cache file if it's older than hours_cached + "$(cache_file)" + delete => default:tidy, + if => isgreaterthan( + # now minus mtime of cache file: + format("%d", eval("$(sys.systime)-$(cache_file_age)")), + # cache minutes: + format("%d", eval("$(hours_cached)*60*60")) + ); + + commands: + _inventory_selinux_semodule_list_modules_supported_platform:: + "/sbin/semodule" + arglist => { "--list-modules=full", ">", "$(cache_file)" }, + # Required for redirection: + contain => default:in_shell, + if => not(fileexists($(cache_file))); +} diff --git a/security/inventory-unshadowed-users/policy/main.cf b/security/inventory-unshadowed-users/policy/main.cf index ddbfe2ad..c1949e29 100644 --- a/security/inventory-unshadowed-users/policy/main.cf +++ b/security/inventory-unshadowed-users/policy/main.cf @@ -1,6 +1,6 @@ body file control { - namespace => "inventory_unshadowed_users"; + namespace => "inventory_unshadowed_users"; } bundle agent main @@ -11,7 +11,15 @@ bundle agent main linux:: "inventory[$(default:parsed_etc_passwd_shadow.all_local_users)]" string => "$(default:parsed_etc_passwd_shadow.all_local_users)", - if => not( strcmp("x", "$(default:parsed_etc_passwd_shadow._passwd_passwd[$(default:parsed_etc_passwd_shadow.all_local_users)])")), - meta => { "inventory", "attribute_name=Local users not using shadowed password" }, + if => not( + strcmp( + "x", + "$(default:parsed_etc_passwd_shadow._passwd_passwd[$(default:parsed_etc_passwd_shadow.all_local_users)])" + ) + ), + meta => { + "inventory", + "attribute_name=Local users not using shadowed password", + }, comment => "Inventory of local user who is not using a shadowed password (lacks 'x' in second field of '/etc/passwd')."; } diff --git a/security/maintainers-in-motd/maintainers-in-motd.cf b/security/maintainers-in-motd/maintainers-in-motd.cf index eae8a07c..a43815bd 100644 --- a/security/maintainers-in-motd/maintainers-in-motd.cf +++ b/security/maintainers-in-motd/maintainers-in-motd.cf @@ -2,17 +2,37 @@ bundle agent maintainers_in_motd { classes: linux:: - "has_maintainer" or => { - isvariable("data:variables.maintainer"), - isvariable("data:variables.maintainer_email") - }; + "has_maintainer" + or => { + isvariable("data:variables.maintainer"), + isvariable("data:variables.maintainer_email"), + }; vars: has_maintainer:: - "maintainer" string => ifelse(isvariable("data:variables.maintainer"),"${data:variables.maintainer}",""); - "maintainer_email" string => ifelse(isvariable("data:variables.maintainer_email"),"(${data:variables.maintainer_email})", "(email missing)"); - "purpose" string => ifelse(isvariable("data:variables.purpose"),"${data:variables.purpose}", "unspecified"); - "message" string => "::: use this machine for ${purpose}, contact ${maintainer}${maintainer_email} with any questions/issues. :::"; + "maintainer" + string => ifelse( + isvariable("data:variables.maintainer"), + "${data:variables.maintainer}", + "" + ); + + "maintainer_email" + string => ifelse( + isvariable("data:variables.maintainer_email"), + "(${data:variables.maintainer_email})", + "(email missing)" + ); + + "purpose" + string => ifelse( + isvariable("data:variables.purpose"), + "${data:variables.purpose}", + "unspecified" + ); + + "message" + string => "::: use this machine for ${purpose}, contact ${maintainer}${maintainer_email} with any questions/issues. :::"; files: has_maintainer:: diff --git a/security/uninstall-apache/uninstall-apache.cf b/security/uninstall-apache/uninstall-apache.cf index 5a9a1f37..ed55213a 100644 --- a/security/uninstall-apache/uninstall-apache.cf +++ b/security/uninstall-apache/uninstall-apache.cf @@ -7,18 +7,21 @@ bundle agent uninstall_apache { vars: redhat|suse:: - "pkg_name" slist => { "httpd" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "httpd" }; + debian:: - "pkg_name" slist => { "apache2" }; # name in apt + # Name in apt: + "pkg_name" slist => { "apache2" }; classes: - "apache_allowed" - or => { - "hardening_apache_allowed", - "data:hardening_apache_allowed", - "exception_uninstall_apache", - "data:exception_uninstall_apache", - }; + "apache_allowed" + or => { + "hardening_apache_allowed", + "data:hardening_apache_allowed", + "exception_uninstall_apache", + "data:exception_uninstall_apache", + }; packages: !apache_allowed:: diff --git a/security/uninstall-bind/uninstall-bind.cf b/security/uninstall-bind/uninstall-bind.cf index 427f0ee5..7ee38e4a 100644 --- a/security/uninstall-bind/uninstall-bind.cf +++ b/security/uninstall-bind/uninstall-bind.cf @@ -7,18 +7,21 @@ bundle agent uninstall_bind { vars: redhat|suse:: - "pkg_name" slist => { "bind" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "bind" }; + debian:: - "pkg_name" slist => { "bind", "bind9" }; # name in apt + # Name in apt: + "pkg_name" slist => { "bind", "bind9" }; classes: - "bind_allowed" - or => { - "hardening_bind_allowed", - "data:hardening_bind_allowed", - "exception_uninstall_bind", - "data:exception_uninstall_bind", - }; + "bind_allowed" + or => { + "hardening_bind_allowed", + "data:hardening_bind_allowed", + "exception_uninstall_bind", + "data:exception_uninstall_bind", + }; packages: !bind_allowed:: diff --git a/security/uninstall-dhcp/uninstall-dhcp.cf b/security/uninstall-dhcp/uninstall-dhcp.cf index de50b0a5..76a13c15 100644 --- a/security/uninstall-dhcp/uninstall-dhcp.cf +++ b/security/uninstall-dhcp/uninstall-dhcp.cf @@ -7,18 +7,21 @@ bundle agent uninstall_dhcp { vars: redhat|suse:: - "pkg_name" slist => { "dhcp" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "dhcp" }; + debian:: - "pkg_name" slist => { "isc-dhcp-server" }; # name in apt + # Name in apt: + "pkg_name" slist => { "isc-dhcp-server" }; classes: - "dhcp_allowed" - or => { - "hardening_dhcp_allowed", - "data:hardening_dhcp_allowed", - "exception_uninstall_dhcp", - "data:exception_uninstall_dhcp", - }; + "dhcp_allowed" + or => { + "hardening_dhcp_allowed", + "data:hardening_dhcp_allowed", + "exception_uninstall_dhcp", + "data:exception_uninstall_dhcp", + }; packages: !dhcp_allowed:: diff --git a/security/uninstall-dovecot/uninstall-dovecot.cf b/security/uninstall-dovecot/uninstall-dovecot.cf index 34e3129d..5e5d88aa 100644 --- a/security/uninstall-dovecot/uninstall-dovecot.cf +++ b/security/uninstall-dovecot/uninstall-dovecot.cf @@ -7,18 +7,21 @@ bundle agent uninstall_dovecot { vars: redhat|suse:: - "pkg_name" slist => { "dovecot" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "dovecot" }; + debian:: - "pkg_name" slist => { "dovecot-core" }; # name in apt + # Name in apt: + "pkg_name" slist => { "dovecot-core" }; classes: - "dovecot_allowed" - or => { - "hardening_dovecot_allowed", - "data:hardening_dovecot_allowed", - "exception_uninstall_dovecot", - "data:exception_uninstall_dovecot", - }; + "dovecot_allowed" + or => { + "hardening_dovecot_allowed", + "data:hardening_dovecot_allowed", + "exception_uninstall_dovecot", + "data:exception_uninstall_dovecot", + }; packages: !dovecot_allowed:: diff --git a/security/uninstall-packages/main.cf b/security/uninstall-packages/main.cf index cced8d52..83d8d7d1 100644 --- a/security/uninstall-packages/main.cf +++ b/security/uninstall-packages/main.cf @@ -6,8 +6,7 @@ body file control bundle agent uninstall_packages { vars: - "i" - slist => getindices(package_names); + "i" slist => getindices(package_names); packages: "$(package_names[$(i)][name])" diff --git a/security/uninstall-rsh-server/uninstall-rsh-server.cf b/security/uninstall-rsh-server/uninstall-rsh-server.cf index b43c04c4..d4d65987 100644 --- a/security/uninstall-rsh-server/uninstall-rsh-server.cf +++ b/security/uninstall-rsh-server/uninstall-rsh-server.cf @@ -7,18 +7,21 @@ bundle agent uninstall_rsh_server { vars: redhat|suse:: - "pkg_name" slist => { "rsh-server" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "rsh-server" }; + debian:: - "pkg_name" slist => { "rsh-server" }; # name in apt + # Name in apt: + "pkg_name" slist => { "rsh-server" }; classes: - "rsh_server_allowed" - or => { - "hardening_rsh_server_allowed", - "data:hardening_rsh_server_allowed", - "exception_uninstall_rsh_server", - "data:exception_uninstall_rsh_server", - }; + "rsh_server_allowed" + or => { + "hardening_rsh_server_allowed", + "data:hardening_rsh_server_allowed", + "exception_uninstall_rsh_server", + "data:exception_uninstall_rsh_server", + }; packages: !rsh_server_allowed:: diff --git a/security/uninstall-samba/uninstall-samba.cf b/security/uninstall-samba/uninstall-samba.cf index c8e3aa40..62579d34 100644 --- a/security/uninstall-samba/uninstall-samba.cf +++ b/security/uninstall-samba/uninstall-samba.cf @@ -7,18 +7,21 @@ bundle agent uninstall_samba { vars: redhat|suse:: - "pkg_name" slist => { "samba" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "samba" }; + debian:: - "pkg_name" slist => { "samba" }; # name in apt + # Name in apt: + "pkg_name" slist => { "samba" }; classes: - "samba_allowed" - or => { - "hardening_samba_allowed", - "data:hardening_samba_allowed", - "exception_uninstall_samba", - "data:exception_uninstall_samba", - }; + "samba_allowed" + or => { + "hardening_samba_allowed", + "data:hardening_samba_allowed", + "exception_uninstall_samba", + "data:exception_uninstall_samba", + }; packages: !samba_allowed:: diff --git a/security/uninstall-squid/uninstall-squid.cf b/security/uninstall-squid/uninstall-squid.cf index 7a5b2f80..124877d1 100644 --- a/security/uninstall-squid/uninstall-squid.cf +++ b/security/uninstall-squid/uninstall-squid.cf @@ -7,18 +7,21 @@ bundle agent uninstall_squid { vars: redhat|suse:: - "pkg_name" slist => { "squid" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "squid" }; + debian:: - "pkg_name" slist => { "squid" }; # name in apt + # Name in apt: + "pkg_name" slist => { "squid" }; classes: - "squid_allowed" - or => { - "hardening_squid_allowed", - "data:hardening_squid_allowed", - "exception_uninstall_squid", - "data:exception_uninstall_squid", - }; + "squid_allowed" + or => { + "hardening_squid_allowed", + "data:hardening_squid_allowed", + "exception_uninstall_squid", + "data:exception_uninstall_squid", + }; packages: !squid_allowed:: diff --git a/security/uninstall-talk/uninstall-talk.cf b/security/uninstall-talk/uninstall-talk.cf index 71c9b379..1d6f94ce 100644 --- a/security/uninstall-talk/uninstall-talk.cf +++ b/security/uninstall-talk/uninstall-talk.cf @@ -7,18 +7,21 @@ bundle agent uninstall_talk { vars: redhat|suse:: - "pkg_name" slist => { "talk", "talk-server" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "talk", "talk-server" }; + debian:: - "pkg_name" slist => { "talk", "talkd" }; # name in apt + # Name in apt: + "pkg_name" slist => { "talk", "talkd" }; classes: - "talk_allowed" - or => { - "hardening_talk_allowed", - "data:hardening_talk_allowed", - "exception_uninstall_talk", - "data:exception_uninstall_talk", - }; + "talk_allowed" + or => { + "hardening_talk_allowed", + "data:hardening_talk_allowed", + "exception_uninstall_talk", + "data:exception_uninstall_talk", + }; packages: !talk_allowed:: diff --git a/security/uninstall-xinetd/uninstall-xinetd.cf b/security/uninstall-xinetd/uninstall-xinetd.cf index 285a9ebe..5e8d653a 100644 --- a/security/uninstall-xinetd/uninstall-xinetd.cf +++ b/security/uninstall-xinetd/uninstall-xinetd.cf @@ -7,18 +7,21 @@ bundle agent uninstall_xinetd { vars: redhat|suse:: - "pkg_name" slist => { "xinetd" }; # Name in yum + # Name in yum: + "pkg_name" slist => { "xinetd" }; + debian:: - "pkg_name" slist => { "xinetd" }; # name in apt + # Name in apt: + "pkg_name" slist => { "xinetd" }; classes: - "xinetd_allowed" - or => { - "hardening_xinetd_allowed", - "data:hardening_xinetd_allowed", - "exception_uninstall_xinetd", - "data:exception_uninstall_xinetd", - }; + "xinetd_allowed" + or => { + "hardening_xinetd_allowed", + "data:hardening_xinetd_allowed", + "exception_uninstall_xinetd", + "data:exception_uninstall_xinetd", + }; packages: !xinetd_allowed:: diff --git a/software/install-ansible/README.md b/software/install-ansible/README.md new file mode 100644 index 00000000..d5f72428 --- /dev/null +++ b/software/install-ansible/README.md @@ -0,0 +1,15 @@ +This module ensures that Ansible is installed on a system. + +## Configuration + +There are three classes which can be used to specify whether to install ansible and which version: minimal or full. + +- `data:install_ansible` - if defined, install ansible (default is full install) +- `data:ansible_minimal_install` - if defined, install minimal version aka ansible-core +- `data:ansible_full_install` - if defined, install full version. + +Additionally it is possible to request a specific version other than the latest/default by way of a variable. + +- `data:install_ansible.version` - if this variable is defined then a specific version will be installed + +Note that the system in question must have a `pipx` package available through the default package manager as well as python installed which should come along as a dependency of `pipx`. diff --git a/software/install-ansible/install-ansible.cf b/software/install-ansible/install-ansible.cf new file mode 100644 index 00000000..b705fb68 --- /dev/null +++ b/software/install-ansible/install-ansible.cf @@ -0,0 +1,58 @@ +# Steps here based on this guide: https://docs.ansible.com/projects/ansible/latest/installation_guide/intro_installation.html +# +# host specific data supported, in the data bundle context +# +# data:install_ansible: class, if defined, install ansible +# data:ansible_minimal_install: class, if defined, minimal install aka ansible-core +# data:ansible_full_install: class, if defined, full install. This is the default if neither minimal or full classes are defined. +bundle agent install_ansible +{ + methods: + linux.(data:install_ansible|data:ansible_minimal_install|data:ansible_full_install):: + "install_ansible_main"; + + reports: + windows:: + "Use WSL to install Ansible on Windows. See https://blog.rolpdog.com/2020/03/why-no-ansible-controller-for-windows.html"; + + !linux.!windows:: + "Installing Ansible on a non-Linux/non-Windows host is not supported"; +} + +bundle agent install_ansible_main +{ + classes: + !windows:: + "ansible_installed" + expression => returnszero("command -v ansible >/dev/null", "useshell"); + + vars: + "install_ansible_command" + string => "${base_command}==${data:install_ansible.version}", + if => isvariable("data:install_ansible.version"); + + "install_ansible_command" + string => "${base_command}", + if => not(isvariable("data:install_ansible.version")); + + data:ansible_minimal_install:: + "base_command" string => "pipx install --global ansible-core"; + + data:ansible_full_install|(!data:ansible_full_install.!data:ansible_minimal_install):: + "base_command" string => "pipx install --global --include-deps ansible"; + + packages: + "pipx" classes => if_ok("pipx_installed"); + + commands: + pipx_installed.!ansible_installed:: + "${install_ansible_command}" + contain => in_shell_umask(022), + comment => "The default umask of 077 would create /opt/pipx/venvs/ansible which is inaccessible to everyone but root, so use 022 instead."; +} + +body contain in_shell_umask(umask) +{ + useshell => "true"; + umask => "${umask}"; +} diff --git a/software/windows/README.md b/software/windows/README.md new file mode 100644 index 00000000..5dd43258 --- /dev/null +++ b/software/windows/README.md @@ -0,0 +1,5 @@ +The `windows-openssh-server` bundle ensures that an openssh server is running on Windows hosts if the class data:openssh_server_installed is defined. + +You can define this in the Host Info page or CMDB. + +This bundle depends on and uses the [windows-capability](https://build.cfengine.com/modules/windows-capability/) module to accomplish the goal. diff --git a/software/windows/windows-openssh-server.cf b/software/windows/windows-openssh-server.cf new file mode 100644 index 00000000..6a15fe15 --- /dev/null +++ b/software/windows/windows-openssh-server.cf @@ -0,0 +1,29 @@ +bundle agent windows_openssh_server +{ + methods: + data:openssh_server_installed:: + "windows_openssh_server_installed"; +} + +bundle agent windows_openssh_server_installed +{ + methods: + windows:: + "Add OpenSSH.Server Capability" + # NOTE: this version "0.0.1.0" is not the actual version but rather a static number that is mysterious to me. + # the actual installed version will be the "latest" + usebundle => windows_capability_installed("OpenSSH.Server~~~~0.0.1.0"), + classes => classes_generic("openssh"); + + services: + windows.openssh_ok:: + "sshd" + service_policy => "start", + service_method => windows_openssh_service_method; +} + +body service_method windows_openssh_service_method +{ + service_type => "windows"; + service_autostart_policy => "boot_time"; +} diff --git a/tests/deploy/00-promise-run-no-errors.sh b/tests/deploy/00-promise-run-no-errors.sh new file mode 100755 index 00000000..53291da5 --- /dev/null +++ b/tests/deploy/00-promise-run-no-errors.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# assumes deploy.sh has already run adjacent to this file + +set -ex +sudo cf-agent -KI > log +if grep 'error:' log; then + grep 'error:' log + exit 1 +fi diff --git a/tests/deploy/01-install-ansible-full-test.sh b/tests/deploy/01-install-ansible-full-test.sh new file mode 100755 index 00000000..29acb7b5 --- /dev/null +++ b/tests/deploy/01-install-ansible-full-test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# assumes deploy.sh has already run adjacent to this file + +set -ex + +sudo pipx uninstall --global ansible || true +sudo pipx uninstall --global ansible-core || true + +sudo cf-agent -Kd -Ddata:install_ansible -Ddata:ansible_full_install --bundle install_ansible > log +if grep 'error:' log; then + grep 'error:' log + exit 1 +fi + +ansible --version +echo "expect that ansible-core is not installed via pipx list" +if ( sudo pipx list --global --short | grep ansible-core ); then + echo "expected only ansible to be installed, but found ansible-core" + exit 1 +fi diff --git a/tests/deploy/02-install-ansible-minimal-test.sh b/tests/deploy/02-install-ansible-minimal-test.sh new file mode 100644 index 00000000..1386973d --- /dev/null +++ b/tests/deploy/02-install-ansible-minimal-test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -ex + +sudo pipx uninstall --global ansible || true +sudo pipx uninstall --global ansible-core || true + +sudo cf-agent -Kd -Ddata:install_ansible -Ddata:ansible_minimal_install --bundle install_ansible > log +if grep 'error:' log; then + grep 'error:' log + exit 1 +fi + +ansible --version +if ! ( sudo pipx list --global --short | grep ansible-core ); then + echo "expected ansible-core to be installed but did not find that in pipx list output" +fi diff --git a/tests/deploy/03-install-ansible-versions-test.sh b/tests/deploy/03-install-ansible-versions-test.sh new file mode 100644 index 00000000..c777563b --- /dev/null +++ b/tests/deploy/03-install-ansible-versions-test.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -ex + +sudo pipx uninstall --global ansible || true +sudo pipx uninstall --global ansible-core || true + +function cleanup +{ + sudo rm -f /var/cfengine/data/host_specific.json || true # to make sure any version data is removed for other tests other runs +} + +trap cleanup EXIT + +good_version="2.21.3" + +sudo mkdir -p /var/cfengine/data +cat < log + +if grep 'error:' log; then + grep 'error:' log + exit 1 +fi + +ansible --version | grep "${good_version}" + +sudo pipx uninstall --global ansible || true +sudo pipx uninstall --global ansible-core || true + +bad_version="x.y.z" +cat < log + +if grep 'error:' log; then + echo "Expected error found due to bad version value. Test passes." + grep 'error:' log + exit 0 +fi + +echo "Expected errors when installing ansible with bad version" +exit 1 diff --git a/tests/deploy/04-install-ansible-default-test.sh b/tests/deploy/04-install-ansible-default-test.sh new file mode 100755 index 00000000..9ecd442e --- /dev/null +++ b/tests/deploy/04-install-ansible-default-test.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# assumes deploy.sh has already run adjacent to this file + +set -ex + +sudo pipx uninstall --global ansible || true +sudo pipx uninstall --global ansible-core || true + +sudo cf-agent -Kd -Ddata:install_ansible --bundle install_ansible > log +if grep 'error:' log; then + grep 'error:' log + exit 1 +fi + +ansible --version +echo "expect that ansible(full) is installed and not ansible-core" +if ( sudo pipx list --global --short | grep ansible-core ); then + echo "expected ansible(full) to be installed, but found ansible-core" + exit 1 +fi diff --git a/tests/deploy/deploy.sh b/tests/deploy/deploy.sh new file mode 100755 index 00000000..9744b2b3 --- /dev/null +++ b/tests/deploy/deploy.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -ex + +echo "WARNING/TODO: This test, $0, requires all changes to be pushed up to a branch in order to test the latest. This could be improved if cfbs add could work with the modules repo and a cfbs.json file. See ENT-14421" +if [ -n "$GITHUB_HEAD_REF" ]; then + # github case + BRANCH="$GITHUB_HEAD_REF" +else + # local case + BRANCH=$(git rev-parse --abbrev-ref HEAD) +fi + +# workaround, if git@ url then we get Error: Cannot specify more than one version of the same module +# so transform the remote url +REPO=$(git remote get-url origin | \ + sed -e 's,git@,https://,' \ + -e 's,com:,com/,' \ +) + +if [ -n "$GITHUB_HEAD_REF" ]; then + REPO="$(echo "$REPO" | sed -e "s,cfengine,$GITHUB_TRIGGERING_ACTOR,")" +fi + +thisdir="$(dirname "$0")" +cd "$thisdir" +[ -d .git ] && rm -rf .git +[ -f cfbs.json ] && rm cfbs.json +cfbs --version +cfbs init --non-interactive +cfbs --non-interactive add "$REPO@$BRANCH" +cfbs build +sudo cfbs install +sudo cf-agent -IB 127.0.0.1 > log + +if grep 'error:' log; then + grep 'error:' log + exit 1 +fi + diff --git a/tests/deploy/test.sh b/tests/deploy/test.sh new file mode 100755 index 00000000..3c8d9c4e --- /dev/null +++ b/tests/deploy/test.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -ex +thisdir="$(dirname "$0")" +bash "$thisdir"/deploy.sh +if ls "$thisdir"/0*.sh >/dev/null; then + for test in "$thisdir"/0*.sh; do + bash -ex "$test" + done +fi