diff --git a/azurelinuxagent/common/conf.py b/azurelinuxagent/common/conf.py index b5eec73ce2..bcc223b28f 100644 --- a/azurelinuxagent/common/conf.py +++ b/azurelinuxagent/common/conf.py @@ -147,7 +147,8 @@ def load_conf_from_file(conf_file_path, conf=__conf__): "Debug.EnableAgentMemoryUsageCheck": False, "Debug.EnableFastTrack": True, "Debug.EnableGAVersioning": True, - "Debug.EnableCgroupV2ResourceLimiting": False + "Debug.EnableCgroupV2ResourceLimiting": False, + "Debug.EnableExtensionPolicy": False } @@ -683,7 +684,15 @@ def get_firewall_rules_log_period(conf=__conf__): """ return conf.get_int("Debug.FirewallRulesLogPeriod", 86400) + +def get_extension_policy_enabled(conf=__conf__): + """ + Determine whether extension policy is enabled. If true, policy will be enforced before installing any extensions. + NOTE: This option is experimental and may be removed in later versions of the Agent. + """ + return conf.get_switch("Debug.EnableExtensionPolicy", False) + def get_enable_cgroup_v2_resource_limiting(conf=__conf__): """ If True, the agent will enable resource monitoring and enforcement for the log collector on machines using cgroup v2. diff --git a/azurelinuxagent/common/event.py b/azurelinuxagent/common/event.py index 7e2b10c991..330e744154 100644 --- a/azurelinuxagent/common/event.py +++ b/azurelinuxagent/common/event.py @@ -86,6 +86,7 @@ class WALAEventOperation: Downgrade = "Downgrade" Download = "Download" Enable = "Enable" + ExtensionPolicy = "ExtensionPolicy" ExtensionProcessing = "ExtensionProcessing" ExtensionTelemetryEventProcessing = "ExtensionTelemetryEventProcessing" FetchGoalState = "FetchGoalState" @@ -111,6 +112,7 @@ class WALAEventOperation: OpenSsl = "OpenSsl" Partition = "Partition" PersistFirewallRules = "PersistFirewallRules" + Policy = "Policy" ProvisionAfterExtensions = "ProvisionAfterExtensions" PluginSettingsVersionMismatch = "PluginSettingsVersionMismatch" InvalidExtensionConfig = "InvalidExtensionConfig" diff --git a/azurelinuxagent/ga/exthandlers.py b/azurelinuxagent/ga/exthandlers.py index d23b1630ce..9915be3e62 100644 --- a/azurelinuxagent/ga/exthandlers.py +++ b/azurelinuxagent/ga/exthandlers.py @@ -476,6 +476,7 @@ def handle_ext_handlers(self, goal_state_id): depends_on_err_msg = None extensions_enabled = conf.get_extensions_enabled() + for extension, ext_handler in all_extensions: handler_i = ExtHandlerInstance(ext_handler, self.protocol, extension=extension) diff --git a/azurelinuxagent/ga/policy/__init__.py b/azurelinuxagent/ga/policy/__init__.py new file mode 100644 index 0000000000..d3897c3d36 --- /dev/null +++ b/azurelinuxagent/ga/policy/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2018 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.6+ and Openssl 1.0+ \ No newline at end of file diff --git a/azurelinuxagent/ga/policy/policy_engine.py b/azurelinuxagent/ga/policy/policy_engine.py new file mode 100644 index 0000000000..5f008dc979 --- /dev/null +++ b/azurelinuxagent/ga/policy/policy_engine.py @@ -0,0 +1,169 @@ +# Copyright 2018 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.4+ and Openssl 1.0+ +# + +from azurelinuxagent.common import logger +from azurelinuxagent.common.version import DISTRO_VERSION, DISTRO_NAME +from azurelinuxagent.common.utils.distro_version import DistroVersion +from azurelinuxagent.common.event import WALAEventOperation, add_event +from azurelinuxagent.common import conf +from azurelinuxagent.common.osutil import get_osutil +import azurelinuxagent.ga.policy.regorus as regorus +from azurelinuxagent.ga.policy.regorus import PolicyError + +# Define support matrix for Regorus and policy engine feature. +# Dict in the format: { distro:min_supported_version } +POLICY_SUPPORTED_DISTROS_MIN_VERSIONS = { + 'ubuntu': DistroVersion('16.04'), + 'mariner': DistroVersion('2'), + 'azurelinux': DistroVersion('3') +} +# TODO: add 'arm64', 'aarch64' here once support is enabled for ARM64 +POLICY_SUPPORTED_ARCHITECTURE = ['x86_64'] + + +class PolicyEngine(object): + """ + Implements base policy engine API. + If any errors are thrown in regorus.py, they will be caught and re-raised here. + The caller will be responsible for handling errors. + """ + def __init__(self, rule_file, policy_file): + """ + Constructor checks that policy enforcement should be enabled, and then sets up the + Regorus policy engine (add rule and policy file). + + rule_file: Path to a Rego file that specifies rules for policy behavior. + + policy_file: Path to a JSON file that specifies parameters for policy behavior - for example, + whether allowlist or extension signing should be enforced. + The expected file format is: + { + "azureGuestAgentPolicy": { + "policyVersion": "0.1.0", + "signingRules": { + "extensionSigned": + }, + "allowListOnly": + }, + "azureGuestExtensionsPolicy": { + "allowed_ext_1": { + "signingRules": { + "extensionSigned": + } + } + } + """ + self._engine = None + if not self.is_policy_enforcement_enabled(): + self._log_policy(msg="Policy enforcement is not enabled.") + return + + # If unsupported, this call will raise an error + self._check_policy_enforcement_supported() + self._engine = regorus.Engine(policy_file=policy_file, rule_file=rule_file) + + @classmethod + def _log_policy(cls, msg, is_success=True, op=WALAEventOperation.Policy, send_event=True): + """ + Log information to console and telemetry. + """ + if is_success: + logger.info(msg) + else: + logger.error(msg) + if send_event: + add_event(op=op, message=msg, is_success=is_success) + + @staticmethod + def is_policy_enforcement_enabled(): + """ + Check whether user has opted into policy enforcement feature. + Caller function should check this before performing any operations. + """ + # TODO: The conf flag will be removed post private preview. Before public preview, add checks + # according to the planned user experience (TBD). + return conf.get_extension_policy_enabled() + + @staticmethod + def _check_policy_enforcement_supported(): + """ + Check that both platform architecture and distro/version are supported. + If supported, do nothing. + If not supported, raise PolicyError with user-friendly error message. + """ + osutil = get_osutil() + arch = osutil.get_vm_arch() + # TODO: surface as a user error with clear instructions for fixing + msg = "Attempted to enable policy enforcement, but feature is not supported on " + if arch not in POLICY_SUPPORTED_ARCHITECTURE: + msg += " architecture " + str(arch) + elif DISTRO_NAME not in POLICY_SUPPORTED_DISTROS_MIN_VERSIONS: + msg += " distro " + str(DISTRO_NAME) + else: + min_version = POLICY_SUPPORTED_DISTROS_MIN_VERSIONS.get(DISTRO_NAME) + if DISTRO_VERSION < min_version: + msg += " distro " + DISTRO_NAME + " " + DISTRO_VERSION + ". Policy is only supported on version " + \ + str(min_version) + " and above." + else: + return # do nothing if platform is supported + raise PolicyError(msg) + + def evaluate_query(self, input_to_check, query): + """ + Input_to_check is the input we want to check against the policy engine (ex: extensions we want to install). + Input_to_check should be a dict. Expected format: + { + "extensions": { + "": { + "signingInfo": { + "extensionSigned": + } + }, ... + } + + The query parameter specifies the value we want to retrieve from the policy engine. + Example format for query: "data.agent_extension_policy.extensions_to_download" + """ + # This method should never be called if policy is not enabled, this would be a developer error. + if not self.is_policy_enforcement_enabled(): + raise PolicyError("Policy enforcement is disabled, cannot evaluate query.") + + try: + full_result = self._engine.eval_query(input_to_check, query) + debug_info = "Rule file is located at '{0}'. \nFull query output: {1}".format(self._engine.rule_file, full_result) + if full_result is None or full_result == {}: + raise PolicyError("query returned empty output. Please validate rule file. {0}".format(debug_info)) + result = full_result.get('result') + if result is None or not isinstance(result, list) or len(result) == 0: + raise PolicyError("query returned unexpected output with no 'result' list. Please validate rule file. {0}".format(debug_info)) + expressions = result[0].get('expressions') + if expressions is None or not isinstance(expressions, list) or len(expressions) == 0: + raise PolicyError("query returned unexpected output with no 'expressions' list. {0}".format(debug_info)) + value = expressions[0].get('value') + if value is None: + raise PolicyError("query returned unexpected output, 'value' not found in 'expressions' list. {0}".format(debug_info)) + if value == {}: + raise PolicyError("query returned expected output format, but value is empty. Please validate policy file '{0}'. '{1}" + .format(self._engine.policy_file, debug_info)) + # TODO: surface as a user error with clear instructions for fixing + return value + except Exception as ex: + msg = "Failed to evaluate query for Regorus policy engine: '{0}'".format(ex) + self._log_policy(msg=msg, is_success=False) + raise PolicyError(msg) + +# TODO: Implement class ExtensionPolicyEngine with API is_extension_download_allowed(ext_name) that calls evaluate_query. diff --git a/azurelinuxagent/ga/policy/regorus.py b/azurelinuxagent/ga/policy/regorus.py new file mode 100644 index 0000000000..f75c1499c4 --- /dev/null +++ b/azurelinuxagent/ga/policy/regorus.py @@ -0,0 +1,146 @@ +# Copyright 2018 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.4+ and Openssl 1.0+ +# + +import json +import tempfile +import azurelinuxagent.common.utils.shellutil as shellutil +from azurelinuxagent.common.utils.shellutil import CommandError +from azurelinuxagent.common.exception import AgentError + + +def get_regorus_path(): + """ + Returns path to Regorus executable. The executable is not yet officially released as part of the agent package. + Currently, the executable is copied into the agent directory for unit testing. + """ + # TODO: update the logic to get regorus path once executable is released as part of agent package + # This method is currently mocked for unit tests. + regorus_exe = "" + return regorus_exe + + +class PolicyError(AgentError): + """ + Error raised during agent policy enforcement. + """ + # TODO: split into two error classes for internal/dev errors and user errors. + + +class Engine: + """ + This class implements the basic operations for the Regorus policy engine via subprocess. + Any errors thrown in this class should be caught and handled by PolicyEngine. + """ + + def __init__(self, policy_file, rule_file): + """ + Rule_file is expected to point to a valid Regorus file. + + Policy_file should be a path to a valid JSON policy (data) file. + The expected file format is: + { + "azureGuestAgentPolicy": { + "policyVersion": "0.1.0", + "signingRules": { + "extensionSigned": + }, + "allowListOnly": + }, + "azureGuestExtensionsPolicy": { + "allowed_ext_1": { + "signingRules": { + "extensionSigned": + } + } + } + """ + self._engine = None + self._rule_file = rule_file + self._policy_file = policy_file + + + @property + def policy_file(self): + return self._policy_file + + @property + def rule_file(self): + return self._rule_file + + def eval_query(self, input_to_check, query): + """ + Input_to_check should be type dict. + Example format for extension policy: + { + "extensions": { + "": { + "signingInfo": { + "extensionSigned": + } + }, + "": { + "signingInfo": { + "extensionSigned": + } + }, ... + } + + In this method, we call the Regorus executable via run_command to query the policy engine. + + Command: + regorus eval -d -d -i + + Parameters: + -d, --data : Rego file or JSON file. + -i, --input : Input file in JSON format. + Query. Rego query block in the format "data." + + Return Codes: + 0 - successful query. optional parameters may be missing + 1 - file error: unsupported file type, error parsing file, file not found + ex: "Error: Unsupported data file . Must be rego or json." + ex: "Error: Failed to read . No such file or directory." + 2 - usage error: missing query argument, unexpected or unlabeled parameter + ex: "Error: the following required arguments were not provided: " + ex: "Error: Unexpected argument found." + """ + # Write input_to_check to a temp file, because Regorus requires input to be a file path. + # Tempfile is automatically cleaned up at the end of with block + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=True) as input_file: + json.dump(input_to_check, input_file, indent=4) + input_file.flush() + + regorus_exe = get_regorus_path() + command = [regorus_exe, "eval", "-d", self._rule_file, "-d", self._policy_file, + "-i", input_file.name, query] + + try: + stdout = shellutil.run_command(command) + except CommandError as ex: + code = ex.returncode + if code == 1: + msg = "file error when using policy engine. {0}".format(ex) + # TODO: surface as a user error with clear instructions for fixing + elif code == 2: + msg = "incorrect parameters passed to policy engine. {0}".format(ex) + # TODO: log this as an internal/developer error and include debug information + else: + msg = "error when using policy engine. {0}".format(ex) + raise PolicyError(msg=msg) + + json_output = json.loads(stdout) + return json_output diff --git a/tests/data/policy/agent-extension-data-invalid.json b/tests/data/policy/agent-extension-data-invalid.json new file mode 100644 index 0000000000..d1eca62b3f --- /dev/null +++ b/tests/data/policy/agent-extension-data-invalid.json @@ -0,0 +1,4 @@ +{ + "policy": { + "invalid_attribute": "0" + } diff --git a/tests/data/policy/agent-extension-default-data.json b/tests/data/policy/agent-extension-default-data.json new file mode 100644 index 0000000000..4ddc83bd7d --- /dev/null +++ b/tests/data/policy/agent-extension-default-data.json @@ -0,0 +1,9 @@ +{ + "azureGuestAgentPolicy": { + "policyVersion": "0.1.0", + "signingRules": { + "extensionSigned": false + }, + "allowListOnly": false + } +} \ No newline at end of file diff --git a/tests/data/policy/agent-extension-input.json b/tests/data/policy/agent-extension-input.json new file mode 100644 index 0000000000..a40a63a8c6 --- /dev/null +++ b/tests/data/policy/agent-extension-input.json @@ -0,0 +1,25 @@ +{ + "extensions": { + "Microsoft.Azure.ActiveDirectory.AADSSHLoginForLinux": { + "signingInfo": { + "extensionSigned": false + } + }, + "test2": { + "signingInfo": { + "extensionSigned": true + } + }, + "test3": { + "signingInfo": { + "extensionSigned": false + } + }, + "test1": { + "signingInfo": { + "extensionSigned": false + } + }, + "test4": {} + } +} \ No newline at end of file diff --git a/tests/data/policy/agent_extension_policy.rego b/tests/data/policy/agent_extension_policy.rego new file mode 100644 index 0000000000..776d79399c --- /dev/null +++ b/tests/data/policy/agent_extension_policy.rego @@ -0,0 +1,139 @@ +# Copyright 2018 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.4+ and Openssl 1.0+ +# + +package agent_extension_policy + +import rego.v1 + +policy_version := "0.1.0" + +default default_global_rules := { + "allowListOnly": false, + "signingRules": { + "extensionSigned": false, + "signingDetails": {}, + }, + "updateAllowed": true, + "uninstallAllowed": true, +} + +default global_rules := { + "allowListOnly": false, + "signingRules": { + "extensionSigned": false, + "signingDetails": {}, + }, + "updateAllowed": true, + "uninstallAllowed": true, +} + +global_rules := object.union(default_global_rules, data.azureGuestAgentPolicy) if { + data.azureGuestAgentPolicy +} + +default any_extension_allowed := true + +any_extension_allowed := false if { + global_rules.allowListOnly +} + +default default_signing_info := {"signingInfo": {}} + +# Download rule 1: if the extension is in the list and download rule satisfied: download allowed +extensions_to_download[name] := extension if { + some name, input_extension in input.extensions + data.azureGuestExtensionsPolicy[name] + download_rule_validated(input_extension, data.azureGuestExtensionsPolicy[name]) + extension := object.union(input_extension, {"downloadAllowed": true}) +} + +# Download rule 2: if the extension is in the list and download rule not satisfied: download denied +extensions_to_download[name] := extension if { + some name, input_extension in input.extensions + data.azureGuestExtensionsPolicy[name] + not download_rule_validated(input_extension, data.azureGuestExtensionsPolicy[name]) + extension := object.union(input_extension, {"downloadAllowed": false}) +} + +# Download rule 3: if the extension is not in the list: depending on allowListOnly on or off +extensions_to_download[name] := extension if { + some name, input_extension in input.extensions + not data.azureGuestExtensionsPolicy[name] + extension := object.union(input_extension, {"downloadAllowed": any_extension_allowed}) +} + +# Validate rule 1: if individual signing rule exists, signing rule validated according to the rules +extensions_validated[name] := extension if { + some name, input_extension in input.extensions + data.azureGuestExtensionsPolicy[name] + + extension_global_rules := object.union(global_rules, data.azureGuestExtensionsPolicy[name]) + extension_signing_info := object.union(extension_global_rules, default_signing_info) + output := object.union(input_extension, extension_signing_info) + signing_validated(output.signingInfo, output.signingRules) + extension := object.union(output, {"signingValidated": true}) +} + +# Validate rule 2: if indivual signing rule exists, signing rule not validated according to the rules +extensions_validated[name] := extension if { + some name, input_extension in input.extensions + data.azureGuestExtensionsPolicy[name] + + extension_global_rules := object.union(global_rules, data.azureGuestExtensionsPolicy[name]) + extension_signing_info := object.union(extension_global_rules, default_signing_info) + output := object.union(input_extension, extension_signing_info) + not signing_validated(output.signingInfo, output.signingRules) + extension := object.union(output, {"signingValidated": false}) +} + +# Validate rule 3: if individual signing rule doesn't exist, signing rule validated according to global signing rule +extensions_validated[name] := extension if { + some name, input_extension in input.extensions + not data.azureGuestExtensionsPolicy[name] + extension_global_rules := object.union(input_extension, global_rules) + output := object.union(extension_global_rules, default_signing_info) + signing_validated(output.signingInfo, output.signingRules) + extension := object.union(output, {"signingValidated": true}) +} + +# Validate rule 4: if individual signing rule doesn't exist, signing rule not validated according to the global rules +extensions_validated[name] := extension if { + some name, input_extension in input.extensions + not data.azureGuestExtensionsPolicy[name] + extension_global_rules := object.union(input_extension, global_rules) + output := object.union(extension_global_rules, default_signing_info) + not signing_validated(output.signingInfo, output.signingRules) + extension := object.union(output, {"signingValidated": false}) +} + +# Currently if download rules doesn't exist, allow the extension because its name is in the list. +# In the future additional rules can be checked with downloadRules present. +download_rule_validated(_, rules) if { + not rules.downloadRules +} + +# Signing is validated if input comes with extension signed, or the input of signing information is matching the +# rules in data. +signing_validated(signingInfo, signingRules) if { + signingInfo + signingRules + signingInfo.extensionSigned +} else if { + signingInfo + signingRules + signingInfo.extensionSigned == signingRules.extensionSigned +} \ No newline at end of file diff --git a/tests/data/policy/agent_extension_policy_invalid.rego b/tests/data/policy/agent_extension_policy_invalid.rego new file mode 100644 index 0000000000..02ca5feb31 --- /dev/null +++ b/tests/data/policy/agent_extension_policy_invalid.rego @@ -0,0 +1,18 @@ +# Copyright 2018 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.4+ and Openssl 1.0+ +# + +# nothing here! \ No newline at end of file diff --git a/tests/data/policy/regorus b/tests/data/policy/regorus new file mode 100755 index 0000000000..f990354037 Binary files /dev/null and b/tests/data/policy/regorus differ diff --git a/tests/ga/test_policy_engine.py b/tests/ga/test_policy_engine.py new file mode 100644 index 0000000000..d31fa6fddd --- /dev/null +++ b/tests/ga/test_policy_engine.py @@ -0,0 +1,141 @@ +# Copyright 2018 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.4+ and Openssl 1.0+ +# + +import json +import os +import shutil + +from tests.lib.tools import AgentTestCase +from azurelinuxagent.ga.policy.policy_engine import PolicyEngine, POLICY_SUPPORTED_DISTROS_MIN_VERSIONS, PolicyError +from tests.lib.tools import patch, data_dir, test_dir + + +class TestPolicyEngine(AgentTestCase): + patcher = None + regorus_dest_path = None # Location where real regorus executable should be. + default_policy_path = os.path.join(data_dir, 'policy', "agent-extension-default-data.json") + default_rule_path = os.path.join(data_dir, 'policy', "agent_extension_policy.rego") + input_json = None # Input is stored in a file, and extracted into this variable during class setup. + + @classmethod + def setUpClass(cls): + + # On a production VM, Regorus will be located in the agent package. Unit tests + # run within the agent directory, so we copy the executable to ga/policy/regorus and patch path. + # Note: Regorus has not been published officially, so for now, unofficial exe is stored in tests/data/policy. + regorus_source_path = os.path.abspath(os.path.join(data_dir, "policy/regorus")) + cls.regorus_dest_path = os.path.abspath(os.path.join(test_dir, "..", "azurelinuxagent/ga/policy/regorus")) + if not os.path.exists(cls.regorus_dest_path): + shutil.copy(regorus_source_path, cls.regorus_dest_path) + cls.patcher = patch('azurelinuxagent.ga.policy.regorus.get_regorus_path', return_value=cls.regorus_dest_path) + cls.patcher.start() + + # We store input in a centralized file, we want to extract the JSON contents into a dict for testing. + # TODO: remove this logic once we add tests for ExtensionPolicyEngine + with open(os.path.join(data_dir, 'policy', "agent-extension-input.json"), 'r') as input_file: + cls.input_json = json.load(input_file) + AgentTestCase.setUpClass() + + @classmethod + def tearDownClass(cls): + # Clean up the Regorus binary that was copied to ga/policy/regorus. + if os.path.exists(cls.regorus_dest_path): + os.remove(cls.regorus_dest_path) + cls.patcher.stop() + AgentTestCase.tearDownClass() + + def test_policy_should_be_enabled_on_supported_distro(self): + """Policy should be enabled on all supported distros.""" + for distro_name, version in POLICY_SUPPORTED_DISTROS_MIN_VERSIONS.items(): + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_NAME', new=distro_name): + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_VERSION', new=version): + with patch('azurelinuxagent.ga.policy.policy_engine.conf.get_extension_policy_enabled', return_value=True): + engine = PolicyEngine(self.default_rule_path, self.default_policy_path) + self.assertTrue(engine.is_policy_enforcement_enabled(), "Policy should be enabled on supported distro {0} {1}".format(distro_name, version)) + + def test_should_raise_exception_on_unsupported_distro(self): + """Policy should NOT be enabled on unsupported distros.""" + test_matrix = { + "rhel": "9.0", + "mariner": "1" + } + for distro_name, version in test_matrix.items(): + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_NAME', new=distro_name): + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_VERSION', new=version): + with patch('azurelinuxagent.ga.policy.policy_engine.conf.get_extension_policy_enabled', return_value=True): + with self.assertRaises(Exception, + msg="Policy should not be enabled on unsupported distro {0} {1}".format(distro_name, version)): + PolicyEngine(self.default_rule_path, self.default_policy_path) + + def test_should_raise_exception_on_unsupported_architecture(self): + """Policy should NOT be enabled on ARM64.""" + # TODO: remove this test when support for ARM64 is added. + with patch('azurelinuxagent.ga.policy.policy_engine.get_osutil') as mock_get_osutil: + with patch('azurelinuxagent.ga.policy.policy_engine.conf.get_extension_policy_enabled', return_value=True): + with self.assertRaises(PolicyError, msg="Policy should not be enabled on unsupported architecture ARM64, should have raised exception."): + mock_get_osutil.get_vm_arch.return_value = "arm64" + PolicyEngine(self.default_rule_path, self.default_policy_path) + + def test_policy_engine_should_evaluate_query(self): + """ + Should be able to initialize policy engine and evaluate query without an error. + """ + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_NAME', new='ubuntu'): + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_VERSION', new='20.04'): + with patch('azurelinuxagent.ga.policy.policy_engine.conf.get_extension_policy_enabled', return_value=True): + engine = PolicyEngine(self.default_rule_path, self.default_policy_path) + query = "data.agent_extension_policy.extensions_to_download" + result = engine.evaluate_query(self.input_json, query) + test_ext_name = "Microsoft.Azure.ActiveDirectory.AADSSHLoginForLinux" + self.assertIsNotNone(result.get(test_ext_name), msg="Query should not have returned empty dict.") + self.assertTrue(result.get(test_ext_name).get('downloadAllowed'), + msg="Query should have returned that extension is allowed.") + + def test_eval_query_should_throw_error_when_disabled(self): + """ + When policy enforcement is disabled, evaluate_query should throw an error. + """ + engine = PolicyEngine(self.default_rule_path, self.default_policy_path) + with self.assertRaises(PolicyError, msg="Should throw error when policy enforcement is disabled."): + engine.evaluate_query(self.input_json, "data") + + def test_should_throw_error_with_invalid_rule_file(self): + """ + Evaluate query with invalid rule file, should throw error. + """ + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_NAME', new='ubuntu'): + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_VERSION', new='20.04'): + with patch('azurelinuxagent.ga.policy.policy_engine.conf.get_extension_policy_enabled', return_value=True): + with self.assertRaises(PolicyError, msg="Should throw error when input is incorrectly formatted."): + # pass policy file instead of rule file in init + invalid_rule = os.path.join(data_dir, 'policy', "agent_extension_policy_invalid.rego") + engine = PolicyEngine(invalid_rule, self.default_policy_path) + engine.evaluate_query(self.input_json, "data") + + def test_should_throw_error_with_invalid_policy_file(self): + """ + Evaluate query with invalid policy file, should throw error. + """ + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_NAME', new='ubuntu'): + with patch('azurelinuxagent.ga.policy.policy_engine.DISTRO_VERSION', new='20.04'): + with patch('azurelinuxagent.ga.policy.policy_engine.conf.get_extension_policy_enabled', return_value=True): + with self.assertRaises(PolicyError, msg="Should throw error when policy file is incorrectly formatted."): + invalid_policy = os.path.join(data_dir, 'policy', "agent-extension-data-invalid.json") + engine = PolicyEngine(self.default_rule_path, invalid_policy) + engine.evaluate_query(self.input_json, "data") + +# TODO: add tests for all combinations of extensions and policy parameters when ExtensionPolicyEngine() class is added \ No newline at end of file diff --git a/tests/ga/test_regorus.py b/tests/ga/test_regorus.py new file mode 100644 index 0000000000..169786f041 --- /dev/null +++ b/tests/ga/test_regorus.py @@ -0,0 +1,99 @@ +# Copyright 2018 Microsoft Corporation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Requires Python 2.4+ and Openssl 1.0+ +# + +import json +import os +import shutil + +from tests.lib.tools import AgentTestCase +from azurelinuxagent.ga.policy.regorus import Engine +from tests.lib.tools import patch, data_dir, test_dir + + +class TestRegorusEngine(AgentTestCase): + patcher = None + regorus_dest_path = None # Location where real regorus executable should be. + default_policy_path = os.path.join(data_dir, 'policy', "agent-extension-default-data.json") + default_rule_path = os.path.join(data_dir, 'policy', "agent_extension_policy.rego") + input_json = None # Input is stored in a file, and extracted into this variable during class setup. + + @classmethod + def setUpClass(cls): + # On a production VM, Regorus will be located in the agent package. Unit tests + # run within the agent directory, so we copy the executable to ga/policy/regorus and patch path. + # Note: Regorus has not been published officially, so for now, unofficial exe is stored in tests/data/policy.s + regorus_source_path = os.path.abspath(os.path.join(data_dir, "policy/regorus")) + cls.regorus_dest_path = os.path.abspath(os.path.join(test_dir, "..", "azurelinuxagent/ga/policy/regorus")) + if not os.path.exists(cls.regorus_dest_path): + shutil.copy(regorus_source_path, cls.regorus_dest_path) + # Patch the path to regorus for all unit tests. + cls.patcher = patch('azurelinuxagent.ga.policy.regorus.get_regorus_path', return_value=cls.regorus_dest_path) + cls.patcher.start() + + # We store input in a centralized file, we want to extract the JSON contents into a dict for testing. + with open(os.path.join(data_dir, 'policy', "agent-extension-input.json"), 'r') as input_file: + cls.input_json = json.load(input_file) + + AgentTestCase.setUpClass() + + @classmethod + def tearDownClass(cls): + # Clean up the Regorus binary that was copied to ga/policy/regorus. + if os.path.exists(cls.regorus_dest_path): + os.remove(cls.regorus_dest_path) + cls.patcher.stop() + AgentTestCase.tearDownClass() + + def test_should_evaluate_query_with_valid_params(self): + """ + Eval_query should return the expected output with a valid policy, data, and input file. + """ + engine = Engine(self.default_policy_path, self.default_rule_path) + output = engine.eval_query(self.input_json, "data.agent_extension_policy.extensions_to_download") + result = output['result'][0]['expressions'][0]['value'] + test_ext_name = "Microsoft.Azure.ActiveDirectory.AADSSHLoginForLinux" + ext_info = result.get(test_ext_name) + self.assertIsNotNone(ext_info, msg="Query failed, should have returned result for extension.") + self.assertTrue(ext_info.get('downloadAllowed')) + + def test_missing_rule_file_should_raise_exception(self): + """Exception should be raised when we eval_query with invalid rule file path.""" + engine = Engine("fake_file_path", self.default_policy_path) + with self.assertRaises(Exception, msg="Adding a bad path to rule file should have raised an exception."): + engine.eval_query(self.input_json, "data") + + def test_invalid_rule_file_should_raise_exception(self): + """Exception should be raised when we eval_query with invalid rule file contents.""" + invalid_rule = os.path.join(data_dir, 'policy', "agent_extension_policy_invalid.rego") + with self.assertRaises(Exception, msg="Adding a rule file with invalid contents should have raised an exception."): + engine = Engine(self.default_policy_path, invalid_rule) + engine.eval_query(self.input_json, "data") + + def test_missing_policy_file_should_raise_exception(self): + """Exception should be raised when we eval_query with invalid policy file path.""" + invalid_policy = os.path.join("agent-extension-data-invalid.json") + with self.assertRaises(Exception, msg="Adding a bad path to policy file should have raised an exception."): + engine = Engine(invalid_policy, self.default_rule_path) + engine.eval_query(self.input_json, "data") + + def test_invalid_policy_file_should_raise_exception(self): + """Exception should be raised when we eval_query with bad data file contents.""" + invalid_policy = os.path.join(data_dir, 'policy', "agent-extension-data-invalid.json") + with self.assertRaises(Exception, msg="Adding an invalid data file should have raised an exception."): + engine = Engine(invalid_policy, self.default_rule_path) + engine.eval_query(self.input_json, "data") + diff --git a/tests/test_agent.py b/tests/test_agent.py index ad3024113b..0ebb91b62a 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -49,6 +49,7 @@ Debug.CgroupMonitorExtensionName = Microsoft.Azure.Monitor.AzureMonitorLinuxAgent Debug.EnableAgentMemoryUsageCheck = False Debug.EnableCgroupV2ResourceLimiting = False +Debug.EnableExtensionPolicy = False Debug.EnableFastTrack = True Debug.EnableGAVersioning = True Debug.EtpCollectionPeriod = 300