diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst index cd3fac513a9..7e03f6c0b0b 100644 --- a/src/confcom/HISTORY.rst +++ b/src/confcom/HISTORY.rst @@ -2,6 +2,13 @@ Release History =============== + +1.2.3 +++++++ +* adding fragment support for VN2 +* bugfix for vn2 workload identities +* no longer encouraged to have multiple images in the same tar file + 1.2.2 ++++++ * support for pure OCI v1 schema 2 formatted images diff --git a/src/confcom/azext_confcom/README.md b/src/confcom/azext_confcom/README.md index 96da3dca571..27f7e76f384 100644 --- a/src/confcom/azext_confcom/README.md +++ b/src/confcom/azext_confcom/README.md @@ -181,22 +181,7 @@ Users just need to make a tar file by using the `docker save` command above, inc When generating security policy without using `--tar` argument, the confcom extension CLI tool attemps to fetch the image remotely if it is not locally available. However, the CLI tool does not attempt to fetch remotely if `--tar` argument is used. -Example 11: The process used in example 10 can also be used to save multiple images into the same tar file. See the following example: - -```bash -docker save ImageTag1 ImageTag2 ImageTag3 -o file.tar -``` - -Disconnect from network and delete the local image from the docker daemon. -Use the following command to generate CCE policy for the image. - -```bash -az confcom acipolicygen -a .\sample-template-input.json --tar .\file.tar -``` - -Note that multiple images saved to the tar file is only available using the docker-archive format for tar files. OCI does not support multi-image tar files at this time. - -Example 12: If it is necessary to put images in their own tarballs, an external file can be used that maps images to their respective tarball paths. See the following example: +Example 11: If it is necessary to put images in their own tarballs, an external file can be used that maps images to their respective tarball paths. See the following example: ```bash docker save image:tag1 -o file1.tar @@ -221,7 +206,7 @@ Use the following command to generate CCE policy for the image. az confcom acipolicygen -a .\sample-template-input.json --tar .\tar_mappings.json ``` -Example 13: Some use cases necessitate the use of regular expressions to allow for environment variables where either their values are secret, or unknown at policy-generation time. For these cases, the workflow below can be used: +Example 12: Some use cases necessitate the use of regular expressions to allow for environment variables where either their values are secret, or unknown at policy-generation time. For these cases, the workflow below can be used: Create parameters in the ARM Template for each environment variable that has an unknown or secret value such as: @@ -292,6 +277,29 @@ Use the following command to generate and print a security policy for an AKS pod az confcom acipolicygen --virtual-node-yaml ./pod.yaml --print-policy ``` +To generate a security policy using a policy config file for Virtual Node, the `scenario` field must be equal to `"vn2"`. This looks like: + +```json +{ + "version": "1.0", + "scenario": "vn2", + "containers": [ + { + "name": "my-image", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.8" + } + } + ] +} +``` + +This `scenario` field adds the necessary environment variables and mount values to containers in the config file. + +### Workload Identity + +To use workload identities with VN2, the associated label [described here](https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview?tabs=dotnet#pod-labels) must be present. Having this will add the requisite environment variables and mounts to each container's policy. + > [!NOTE] > The `acipolicygen` command is specific to generating policies for ACI-based containers. For generating security policies for the [Confidential Containers on AKS](https://learn.microsoft.com/en-us/azure/aks/confidential-containers-overview) feature, use the `katapolicygen` command. diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index f990b71017c..1dc0d2e929d 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -24,6 +24,7 @@ validate_fragment_json_policy, validate_image_target, validate_upload_fragment, + validate_infrastructure_svn, ) @@ -88,6 +89,7 @@ def load_arguments(self, _): options_list=("--infrastructure-svn",), required=False, help="Minimum Allowed Software Version Number for Infrastructure Fragment", + validator=validate_infrastructure_svn, ) c.argument( "debug_mode", diff --git a/src/confcom/azext_confcom/_validators.py b/src/confcom/azext_confcom/_validators.py index 8941e8080c8..0fd444c1486 100644 --- a/src/confcom/azext_confcom/_validators.py +++ b/src/confcom/azext_confcom/_validators.py @@ -24,6 +24,11 @@ def validate_print_format(namespace): raise CLIError("Can only print in one format at a time") +def validate_infrastructure_svn(namespace): + if namespace.infrastructure_svn and namespace.exclude_default_fragments: + raise CLIError("Cannot set infrastructure SVN without using default fragments") + + def validate_aci_source(namespace): if sum(map(bool, [ namespace.input_path, diff --git a/src/confcom/azext_confcom/config.py b/src/confcom/azext_confcom/config.py index 32fb6eff5cb..60e6a9f360a 100644 --- a/src/confcom/azext_confcom/config.py +++ b/src/confcom/azext_confcom/config.py @@ -11,6 +11,7 @@ ACI_FIELD_RESOURCES = "resources" ACI_FIELD_RESOURCES_NAME = "name" ACI_FIELD_CONTAINERS = "containers" +ACI_FIELD_SCENARIO = "scenario" ACI_FIELD_CONTAINERS_NAME = "name" ACI_FIELD_CONTAINERS_CONTAINERIMAGE = "containerImage" ACI_FIELD_CONTAINERS_ENVS = "environmentVariables" @@ -63,6 +64,7 @@ ACI_FIELD_TEMPLATE_RESOURCE_LABEL = "Microsoft.ContainerInstance/containerGroups" ACI_FIELD_TEMPLATE_RESOURCE_PROFILE_LABEL = "Microsoft.ContainerInstance/containerGroupProfiles" ACI_FIELD_TEMPLATE_COMMAND = "command" +ACI_FIELD_TEMPLATE_ENTRYPOINT = "entrypoint" ACI_FIELD_TEMPLATE_ENVS = "environmentVariables" ACI_FIELD_TEMPLATE_VOLUME_MOUNTS = "volumeMounts" ACI_FIELD_TEMPLATE_MOUNTS_TYPE = "mountType" @@ -84,8 +86,12 @@ ACI_FIELD_YAML_LIVENESS_PROBE = "livenessProbe" ACI_FIELD_YAML_READINESS_PROBE = "readinessProbe" ACI_FIELD_YAML_STARTUP_PROBE = "startupProbe" +ACI_FIELD_TEMPLATE_SPECIAL_ENV_VAR_REGEX_NAME = "THIM_ENDPOINT" +ACI_FIELD_TEMPLATE_SPECIAL_ENV_VAR_REGEX_VALUE = "^===CONFIDENTIAL.THIM.ENDPOINT===$" + VIRTUAL_NODE_YAML_METADATA = "metadata" VIRTUAL_NODE_YAML_COMMAND = "command" +VIRTUAL_NODE_YAML_ARGS = "args" VIRTUAL_NODE_YAML_NAME = "name" VIRTUAL_NODE_YAML_ANNOTATIONS = "annotations" VIRTUAL_NODE_YAML_LABELS = "labels" @@ -158,6 +164,11 @@ REGO_CONTAINER_START = "containers := " REGO_FRAGMENT_START = "fragments := " +# scenario options +VN2 = "vn2" +ACI = "aci" +KATA = "kata" + CONFIG_FILE = "./data/internal_config.json" @@ -191,6 +202,7 @@ DEFAULT_MOUNTS_USER_VIRTUAL_NODE = _config["mount"]["default_mounts_user_virtual_node"] DEFAULT_MOUNTS_VIRTUAL_NODE = _config["mount"]["default_mounts_virtual_node"] DEFAULT_MOUNTS_PRIVILEGED_VIRTUAL_NODE = _config["mount"]["default_mounts_virtual_node_privileged"] +DEFAULT_MOUNTS_WORKLOAD_IDENTITY_VIRTUAL_NODE = _config["mount"]["default_mounts_workload_identity_virtual_node"] # default mounts policy options for all containers DEFAULT_MOUNT_POLICY = _config["mount"]["default_policy"] # default rego policy to be added to all user containers diff --git a/src/confcom/azext_confcom/container.py b/src/confcom/azext_confcom/container.py index 6e2172ca42b..fcdc063f33b 100644 --- a/src/confcom/azext_confcom/container.py +++ b/src/confcom/azext_confcom/container.py @@ -133,6 +133,19 @@ def extract_working_dir(container_json: Any) -> str: return workingDir +def extract_entrypoint(container_json: Any) -> List[str]: + # parse entrypoint. can either be a list of strings or None in the case of non-VN2 policy generation + entrypoint = case_insensitive_dict_get( + container_json, config.ACI_FIELD_TEMPLATE_ENTRYPOINT + ) + if not isinstance(entrypoint, list) and entrypoint is not None: + eprint( + f'Field ["{config.ACI_FIELD_CONTAINERS}"]' + + f'["{config.ACI_FIELD_TEMPLATE_ENTRYPOINT}"] must be list of Strings.' + ) + return entrypoint + + def extract_command(container_json: Any) -> List[str]: # parse command command = case_insensitive_dict_get( @@ -512,7 +525,7 @@ def extract_get_signals(container_json: Any) -> List: class ContainerImage: - # pylint: disable=too-many-instance-attributes + # pylint: disable=too-many-instance-attributes, too-many-public-methods @classmethod def from_json( @@ -523,6 +536,7 @@ def from_json( id_val = extract_id(container_json) container_name = extract_container_name(container_json) environment_rules = extract_env_rules(container_json=container_json) + entrypoint = extract_entrypoint(container_json) command = extract_command(container_json) working_dir = extract_working_dir(container_json) mounts = extract_mounts(container_json) @@ -543,6 +557,7 @@ def from_json( containerImage=container_image, containerName=container_name, environmentRules=environment_rules, + entrypoint=entrypoint, command=command, workingDir=working_dir, mounts=mounts, @@ -568,6 +583,7 @@ def __init__( allow_elevated: bool, id_val: str, extraEnvironmentRules: Dict, + entrypoint: List[str] = None, capabilities: Dict = copy.deepcopy(_CAPABILITIES), user: Dict = copy.deepcopy(_DEFAULT_USER), seccomp_profile_sha256: str = "", @@ -575,7 +591,7 @@ def __init__( allowPrivilegeEscalation: bool = True, execProcesses: List = None, signals: List = None, - containerName: str = "" + containerName: str = "", ) -> None: self.containerImage = containerImage self.containerName = containerName @@ -584,6 +600,7 @@ def __init__( else: self.base, self.tag = containerImage, "latest" self._environmentRules = environmentRules + self._entrypoint = entrypoint self._command = command self._workingDir = workingDir self._layers = [] @@ -621,6 +638,11 @@ def set_signals(self, signals: List) -> None: def set_working_dir(self, workingDir: str) -> None: self._workingDir = workingDir + # note that entrypoint is only used for VN2 containers because of kubernetes discrepancy in naming + # entrypoint -> command, args -> command + def get_entrypoint(self) -> List[str]: + return self._entrypoint + def get_command(self) -> List[str]: return self._command @@ -645,6 +667,9 @@ def set_user(self, user: Dict) -> None: def get_mounts(self) -> List: return self._mounts + def set_mounts(self, mounts) -> None: + self._mounts = mounts + def get_seccomp_profile_sha256(self) -> str: return self._seccomp_profile_sha256 @@ -774,10 +799,11 @@ def from_json( image.get_mounts().extend(_DEFAULT_MOUNTS_VN2) # Start with the customer environment rules - env_rules = _INJECTED_CUSTOMER_ENV_RULES + env_rules = copy.deepcopy(_INJECTED_CUSTOMER_ENV_RULES) # If is_vn2, add the VN2 environment rules if is_vn2: env_rules += _INJECTED_SERVICE_VN2_ENV_RULES + image.set_mounts(image.get_mounts() + copy.deepcopy(config.DEFAULT_MOUNTS_VIRTUAL_NODE)) image.set_extra_environment_rules(env_rules) return image diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index 3cbf3f764c7..8b969bfee67 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -144,7 +144,10 @@ def acipolicygen_confcom( debug_mode=debug_mode, disable_stdio=disable_stdio, approve_wildcards=approve_wildcards, - diff_mode=diff + diff_mode=diff, + rego_imports=fragments_list, + exclude_default_fragments=exclude_default_fragments, + infrastructure_svn=infrastructure_svn, ) exit_code = 0 diff --git a/src/confcom/azext_confcom/data/internal_config.json b/src/confcom/azext_confcom/data/internal_config.json index e9aa17a5b3b..274c72cec37 100644 --- a/src/confcom/azext_confcom/data/internal_config.json +++ b/src/confcom/azext_confcom/data/internal_config.json @@ -1,5 +1,5 @@ { - "version": "1.2.2", + "version": "1.2.3", "hcsshim_config": { "maxVersion": "1.0.0", "minVersion": "0.0.1" @@ -282,6 +282,14 @@ "mountType": "emptyDir", "mountPath": "/etc/hostname" } + ], + "default_mounts_workload_identity_virtual_node": [ + { + "name": "azure-tokens", + "mountType": "emptyDir", + "mountPath": "/var/run/secrets/azure/tokens", + "readonly": true + } ] }, "sidecar_base_names": [ diff --git a/src/confcom/azext_confcom/security_policy.py b/src/confcom/azext_confcom/security_policy.py index 4fbf17f31ee..6d6d49cdf4a 100644 --- a/src/confcom/azext_confcom/security_policy.py +++ b/src/confcom/azext_confcom/security_policy.py @@ -401,7 +401,7 @@ def _policy_serialization(self, pretty_print=False, include_sidecars: bool = Tru return pretty_print_func(policy) return print_func(policy) - # pylint: disable=R0914, R0915 + # pylint: disable=R0914, R0915, R0912 def populate_policy_content_for_all_images( self, individual_image=False, tar_mapping=None, faster_hashing=False, ) -> None: @@ -416,6 +416,12 @@ def populate_policy_content_for_all_images( proxy = self._get_rootfs_proxy() container_images = self.get_images() + if isinstance(tar_mapping, str) and len(container_images) > 1: + eprint( + "Cannot have only one tar file when generating policy for multiple images. " + + "Please create a json file that maps image name to tar file path" + ) + # total tasks to complete is number of images to pull and get layers # (i.e. total images * 2 tasks) _TOTAL = 2 * len(container_images) @@ -446,8 +452,28 @@ def populate_policy_content_for_all_images( if ( isinstance(image, UserContainerImage) or individual_image ) and image_info: - # verify and populate the startup command - if not image.get_command(): + # verify and populate the startup command for VN2 since "command" and "args" + # can be set independent of each other. These names correspond to what we call + # "entrypoint" and "command" + # entrypoint should be None for everything except VN2 + image_entrypoint = image.get_entrypoint() + if image_entrypoint is not None: + image_command = image.get_command() + manifest_entrypoint = image_info.get("Entrypoint") or [] + manifest_command = image_info.get("Cmd") or [] + # pylint: disable=line-too-long + # this describes the potential options that can happen: https://unofficial-kubernetes.readthedocs.io/en/latest/concepts/configuration/container-command-args/ + if image_entrypoint and not image_command: + command = image_entrypoint + elif image_entrypoint and image_command: + command = image_entrypoint + image_command + elif image_command: + command = manifest_entrypoint + image_command + else: + command = manifest_entrypoint + manifest_command + image.set_command(command) + + elif not image.get_command(): # precondition: image_info exists. this is shown by the # "and image_info" earlier command = image_info.get("Cmd") @@ -455,6 +481,7 @@ def populate_policy_content_for_all_images( # since we don't have an entrypoint field, # it needs to be added to the front of the command # array + # update: there is now an entrypoint field for VN2 use cases entrypoint = image_info.get("Entrypoint") if entrypoint and command: command = entrypoint + command @@ -1012,6 +1039,10 @@ def load_policy_from_virtual_node_yaml_file( disable_stdio: bool = False, approve_wildcards: bool = False, diff_mode: bool = False, + rego_imports: list = None, + exclude_default_fragments: bool = False, + fragment_contents: list = None, + infrastructure_svn: str = None, ) -> List[AciPolicy]: yaml_contents_str = os_util.load_str_from_file(virtual_node_yaml_path) return load_policy_from_virtual_node_yaml_str( @@ -1020,6 +1051,10 @@ def load_policy_from_virtual_node_yaml_file( disable_stdio=disable_stdio, approve_wildcards=approve_wildcards, diff_mode=diff_mode, + rego_imports=rego_imports, + exclude_default_fragments=exclude_default_fragments, + fragment_contents=fragment_contents, + infrastructure_svn=infrastructure_svn, ) @@ -1030,6 +1065,10 @@ def load_policy_from_virtual_node_yaml_str( disable_stdio: bool = False, approve_wildcards: bool = False, diff_mode: bool = False, + rego_imports: list = None, + exclude_default_fragments: bool = False, + fragment_contents: Any = None, + infrastructure_svn: str = None, ) -> List[AciPolicy]: """ Load a virtual node yaml file and generate a policy object @@ -1056,10 +1095,7 @@ def load_policy_from_virtual_node_yaml_str( # extract existing policy and fragments for diff mode metadata = case_insensitive_dict_get(yaml, config.VIRTUAL_NODE_YAML_METADATA) annotations = case_insensitive_dict_get(metadata, config.VIRTUAL_NODE_YAML_ANNOTATIONS) - labels = case_insensitive_dict_get(metadata, config.VIRTUAL_NODE_YAML_LABELS) or [] - use_workload_identity = ( - config.VIRTUAL_NODE_YAML_LABEL_WORKLOAD_IDENTITY in labels - and labels.get(config.VIRTUAL_NODE_YAML_LABEL_WORKLOAD_IDENTITY) == "true") + existing_policy = case_insensitive_dict_get(annotations, config.VIRTUAL_NODE_YAML_POLICY) try: if existing_policy: @@ -1075,6 +1111,12 @@ def load_policy_from_virtual_node_yaml_str( normalized_yaml = convert_to_pod_spec(yaml) volume_claim_templates = get_volume_claim_templates(yaml) + normalized_metadata = case_insensitive_dict_get(normalized_yaml, config.VIRTUAL_NODE_YAML_METADATA) + labels = case_insensitive_dict_get(normalized_metadata, config.VIRTUAL_NODE_YAML_LABELS) or [] + use_workload_identity = ( + config.VIRTUAL_NODE_YAML_LABEL_WORKLOAD_IDENTITY in labels + and labels.get(config.VIRTUAL_NODE_YAML_LABEL_WORKLOAD_IDENTITY) == "true") + spec = case_insensitive_dict_get(normalized_yaml, "spec") if not spec: eprint("YAML file does not contain a spec field") @@ -1091,6 +1133,18 @@ def load_policy_from_virtual_node_yaml_str( # e.g. lifecycle and probes are not supported in initContainers init_containers = case_insensitive_dict_get(spec, config.ACI_FIELD_TEMPLATE_INIT_CONTAINERS) or [] + rego_fragments = copy.deepcopy(config.DEFAULT_REGO_FRAGMENTS) if not exclude_default_fragments else [] + if infrastructure_svn: + # assumes the first DEFAULT_REGO_FRAGMENT is always the + # infrastructure fragment + rego_fragments[0][ + config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN + ] = infrastructure_svn + if rego_imports: + # error check the rego imports for invalid data types + processed_imports = process_fragment_imports(rego_imports) + rego_fragments.extend(processed_imports) + for container in containers + init_containers: # image and name image = case_insensitive_dict_get(container, config.ACI_FIELD_TEMPLATE_IMAGE) @@ -1104,17 +1158,19 @@ def load_policy_from_virtual_node_yaml_str( secrets_data, approve_wildcards=approve_wildcards ) - if use_workload_identity: - envs += config.VIRTUAL_NODE_ENV_RULES_WORKLOAD_IDENTITY # command command = case_insensitive_dict_get(container, config.VIRTUAL_NODE_YAML_COMMAND) or [] - args = case_insensitive_dict_get(container, "args") or [] + args = case_insensitive_dict_get(container, config.VIRTUAL_NODE_YAML_ARGS) or [] # mounts - mounts = copy.deepcopy(config.DEFAULT_MOUNTS_VIRTUAL_NODE) + mounts = [] volumes = case_insensitive_dict_get(spec, "volumes") or [] + if use_workload_identity: + envs += config.VIRTUAL_NODE_ENV_RULES_WORKLOAD_IDENTITY + mounts += config.DEFAULT_MOUNTS_WORKLOAD_IDENTITY_VIRTUAL_NODE + # there can be implicit volumes from volumeClaimTemplates # We need to add them to the list of volumes and note if they are readonly for volume_claim_template in volume_claim_templates: @@ -1208,7 +1264,8 @@ def load_policy_from_virtual_node_yaml_str( container, config.VIRTUAL_NODE_YAML_NAME) or image, config.ACI_FIELD_CONTAINERS_CONTAINERIMAGE: image, config.ACI_FIELD_CONTAINERS_ENVS: envs, - config.ACI_FIELD_CONTAINERS_COMMAND: command + args, + config.ACI_FIELD_TEMPLATE_ENTRYPOINT: command, + config.ACI_FIELD_CONTAINERS_COMMAND: args, config.ACI_FIELD_CONTAINERS_MOUNTS: mounts, config.ACI_FIELD_CONTAINERS_EXEC_PROCESSES: exec_processes + config.DEBUG_MODE_SETTINGS.get("execProcesses") @@ -1228,9 +1285,11 @@ def load_policy_from_virtual_node_yaml_str( }, debug_mode=debug_mode, disable_stdio=disable_stdio, - rego_fragments=copy.deepcopy(config.DEFAULT_REGO_FRAGMENTS), + rego_fragments=rego_fragments, + # fallback to default fragments if the policy is not present is_vn2=True, existing_rego_fragments=existing_fragments, + fragment_contents=fragment_contents, ) ) return all_policies @@ -1254,6 +1313,10 @@ def load_policy_from_config_str(config_str, debug_mode: bool = False, disable_st config_dict, config.ACI_FIELD_CONTAINERS ) + scenario = case_insensitive_dict_get( + config_dict, config.ACI_FIELD_SCENARIO + ) or "" + for container in container_list: container_name = case_insensitive_dict_get( container, config.ACI_FIELD_CONTAINERS_NAME @@ -1286,6 +1349,17 @@ def load_policy_from_config_str(config_str, debug_mode: bool = False, disable_st extract_probe(exec_processes, container_properties, config.ACI_FIELD_CONTAINERS_READINESS_PROBE) extract_probe(exec_processes, container_properties, config.ACI_FIELD_CONTAINERS_LIVENESS_PROBE) + container_security_context = case_insensitive_dict_get( + container_properties, config.ACI_FIELD_TEMPLATE_SECURITY_CONTEXT + ) or {} + + mounts = process_mounts_from_config(container_properties) + process_configmap(container_properties) + if ( + scenario.lower() == config.VN2 and + case_insensitive_dict_get(container_security_context, config.ACI_FIELD_CONTAINERS_PRIVILEGED) is True + ): + mounts += config.DEFAULT_MOUNTS_PRIVILEGED_VIRTUAL_NODE + containers.append( { config.ACI_FIELD_CONTAINERS_ID: image_name, @@ -1298,8 +1372,7 @@ def load_policy_from_config_str(config_str, debug_mode: bool = False, disable_st container_properties, config.ACI_FIELD_TEMPLATE_COMMAND ) or [], - config.ACI_FIELD_CONTAINERS_MOUNTS: process_mounts_from_config(container_properties) - + process_configmap(container_properties), + config.ACI_FIELD_CONTAINERS_MOUNTS: mounts, config.ACI_FIELD_CONTAINERS_EXEC_PROCESSES: exec_processes + config.DEBUG_MODE_SETTINGS.get("execProcesses") if debug_mode @@ -1320,4 +1393,5 @@ def load_policy_from_config_str(config_str, debug_mode: bool = False, disable_st disable_stdio=disable_stdio, rego_fragments=rego_fragments, debug_mode=debug_mode, + is_vn2=scenario.lower() == config.VN2, ) diff --git a/src/confcom/azext_confcom/template_util.py b/src/confcom/azext_confcom/template_util.py index d8b25a400e3..a1089cf71f3 100644 --- a/src/confcom/azext_confcom/template_util.py +++ b/src/confcom/azext_confcom/template_util.py @@ -244,6 +244,16 @@ def process_env_vars_from_template(params: dict, config.ACI_FIELD_CONTAINERS_ENVS_VALUE: ".*", config.ACI_FIELD_CONTAINERS_ENVS_STRATEGY: "re2", }) + elif ( + re.match(config.ACI_FIELD_TEMPLATE_SPECIAL_ENV_VAR_REGEX_VALUE, value) and + name == config.ACI_FIELD_TEMPLATE_SPECIAL_ENV_VAR_REGEX_NAME + ): + # special case for adding THIM endpoint to container + env_vars.append({ + config.ACI_FIELD_CONTAINERS_ENVS_NAME: config.ACI_FIELD_TEMPLATE_SPECIAL_ENV_VAR_REGEX_NAME, + config.ACI_FIELD_CONTAINERS_ENVS_VALUE: ".*", + config.ACI_FIELD_CONTAINERS_ENVS_STRATEGY: "re2", + }) else: env_vars.append({ config.ACI_FIELD_CONTAINERS_ENVS_NAME: name, diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py b/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py index cd9f315ab76..49e03b90e46 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py @@ -855,7 +855,7 @@ def test_arm_template_with_parameter_file(self): # see if we have environment variables specific to the python image in the parameter file python_flag = False for rules in output_json[0][config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS]: - if "PYTHON" in rules[config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS_RULE]: + if "python" in rules[config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS_RULE]: python_flag = True self.assertTrue(python_flag) @@ -970,6 +970,12 @@ def test_arm_template_with_parameter_file_injected_env_vars(self): "name": "[parameters('containername')]", "properties": { "image": "[parameters('image')]", + "environmentVariables": [ + { + "name": "THIM_ENDPOINT", + "value": "===CONFIDENTIAL.THIM.ENDPOINT===" + } + ], "ports": [ { "port": "[parameters('port')]" @@ -1023,10 +1029,15 @@ def test_arm_template_with_parameter_file_injected_env_vars(self): # see if we have environment variables specific to the python image in the parameter file python_flag = False + thim_flag = False for value in output_json[0][config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS]: - if "PYTHON" in value[config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS_RULE]: + if "python" in value[config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS_RULE]: python_flag = True + if f"{config.ACI_FIELD_TEMPLATE_SPECIAL_ENV_VAR_REGEX_NAME}=.*" == value[config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS_RULE]: + thim_flag = True self.assertTrue(python_flag) + self.assertTrue(thim_flag) + class PolicyGeneratingArmContainerConfig(unittest.TestCase): diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py index 862d6377f1d..743ddb41d85 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -6,7 +6,6 @@ import os import unittest import json -import time import subprocess from knack.util import CLIError @@ -22,6 +21,7 @@ from azext_confcom.template_util import ( case_insensitive_dict_get, extract_containers_and_fragments_from_text, + decompose_confidential_properties, ) from azext_confcom.os_util import ( write_str_to_file, @@ -29,6 +29,9 @@ load_str_from_file, load_json_from_str, delete_silently, + write_str_to_file, + force_delete_silently, + str_to_base64, ) from azext_confcom.custom import acifragmentgen_confcom from azure.cli.testsdk import ScenarioTest @@ -68,6 +71,48 @@ class FragmentMountEnforcement(unittest.TestCase): } ] } + """ + custom_json2 = """ +{ + "version": "1.0", + "fragments": [], + "scenario": "vn2", + "containers": [ + { + "name": "simple-container", + "properties": { + "image": "mcr.microsoft.com/cbl-mariner/distroless/python:3.9-nonroot", + "environmentVariables": [ + { + "name": "PATH", + "value": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + } + ], + "command": [ + "python3" + ], + "securityContext": { + "allowPrivilegeEscalation": true, + "privileged": true + }, + "volumeMounts": [ + { + "name": "logs", + "mountType": "emptyDir", + "mountPath": "/aci/logs", + "readonly": false + }, + { + "name": "secret", + "mountType": "emptyDir", + "mountPath": "/aci/secret", + "readonly": true + } + ] + } + } + ] +} """ aci_policy = None @@ -159,6 +204,36 @@ def test_fragment_user_container_mount_injected_dns(self): mount[config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS_OPTIONS][2], "rw" ) + def test_virtual_node_policy_fragment_generation(self): + try: + fragment_filename = "policy_fragment_file.json" + write_str_to_file(fragment_filename, self.custom_json2) + rego_filename = "example_fragment_file" + acifragmentgen_confcom(None, fragment_filename, None, rego_filename, "1", "test_feed_file", None, None, None) + + containers, _ = decompose_confidential_properties(str_to_base64(load_str_from_file(f"{rego_filename}.rego"))) + + custom_container = containers[0] + vn2_privileged_mounts = [x.get(config.ACI_FIELD_CONTAINERS_MOUNTS_PATH) for x in config.DEFAULT_MOUNTS_PRIVILEGED_VIRTUAL_NODE] + vn2_mounts = [x.get(config.ACI_FIELD_CONTAINERS_MOUNTS_PATH) for x in config.DEFAULT_MOUNTS_VIRTUAL_NODE] + + vn2_mount_count = 0 + priv_mount_count = 0 + for mount in custom_container.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS): + mount_name = mount.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS_DESTINATION) + + if mount_name in vn2_privileged_mounts: + priv_mount_count += 1 + if mount_name in vn2_mounts: + vn2_mount_count += 1 + if priv_mount_count != len(vn2_privileged_mounts): + self.fail("policy does not contain privileged vn2 mounts") + if vn2_mount_count != len(vn2_mounts): + self.fail("policy does not contain default vn2 mounts") + finally: + force_delete_silently(fragment_filename) + force_delete_silently(f"{rego_filename}.rego") + class FragmentGenerating(unittest.TestCase): custom_json = """ diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_scenario.py b/src/confcom/azext_confcom/tests/latest/test_confcom_scenario.py index 1610c68c73b..ca83719070e 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_scenario.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_scenario.py @@ -577,8 +577,8 @@ def test_image_layers_python(self): aci_policy.populate_policy_content_for_all_images() layers = aci_policy.get_images()[0]._layers expected_layers = [ - "6ee0f2697647b69975229db7445260a1c9ae5b039f9a52a911e72b6c3302d391", - "3ba5dd39bf58f28b7e57a52d10edf1813fe3b1fd1d0ef57573b1b912d6b9d1f6" + "f4dbab29a5ddebfa8866d72df10d284ffb2b11292baf8d184b7bc1241d5074fd", + "c003cd912b804162760beb676e5b629a297d75fbdcffd2d3ee49bacc0b985a52" ] self.assertEqual(len(layers), len(expected_layers)) for i in range(len(expected_layers)): diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py index f0cfe6f1e3f..5567a266226 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py @@ -21,6 +21,7 @@ ) import azext_confcom.config as config from azext_confcom.template_util import DockerClient +from azext_confcom.os_util import write_json_to_file def create_tar_file(image_path: str) -> None: @@ -571,9 +572,19 @@ def test_arm_template_mixed_mode_tar(self): filename = os.path.join(self.path, "./mariner2.tar") create_tar_file(filename) + image_mapping = {"mcr.microsoft.com/cbl-mariner/distroless/python:3.9-nonroot": filename} + + # check to make sure many:1 mapping doesn't work + with self.assertRaises(SystemExit) as exc_info: + clean_room_image.populate_policy_content_for_all_images( + tar_mapping=filename + ) + self.assertEqual(exc_info.exception.code, 1) + clean_room_image.populate_policy_content_for_all_images( - tar_mapping=filename + tar_mapping=image_mapping ) + remove_tar_file(filename) regular_image_json = json.loads( regular_image.get_serialized_output(output_type=OutputType.RAW, rego_boilerplate=False) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py b/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py index ad4f3c80ba5..a3d6d8fbec5 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_virtual_node.py @@ -6,7 +6,9 @@ import os import unittest import json +import subprocess import azext_confcom.config as config +import azext_confcom.os_util as os_util from azext_confcom.template_util import extract_containers_from_text from azext_confcom.security_policy import ( load_policy_from_str, @@ -14,6 +16,10 @@ OutputType, decompose_confidential_properties ) +from azext_confcom.custom import ( + acipolicygen_confcom, + acifragmentgen_confcom, +) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) @@ -53,6 +59,44 @@ class PolicyGeneratingVirtualNode(unittest.TestCase): ] } """ + custom_json2 = """ +{ + "version": "1.0", + "fragments": [], + "scenario": "vn2", + "containers": [ + { + "name": "simple-container", + "properties": { + "image": "mcr.microsoft.com/cbl-mariner/distroless/python:3.9-nonroot", + "environmentVariables": [ + { + "name": "PATH", + "value": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + } + ], + "command": [ + "python3" + ], + "volumeMounts": [ + { + "name": "logs", + "mountType": "emptyDir", + "mountPath": "/aci/logs", + "readonly": false + }, + { + "name": "secret", + "mountType": "emptyDir", + "mountPath": "/aci/secret", + "readonly": true + } + ] + } + } + ] +} + """ custom_yaml = """ apiVersion: v1 @@ -271,6 +315,55 @@ class PolicyGeneratingVirtualNode(unittest.TestCase): storage: 1Gi """ + custom_yaml_command = """ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: web +spec: + serviceName: "container" + replicas: 2 + selector: + matchLabels: + app: container + template: + metadata: + labels: + app: container + spec: + containers: + - name: container + image: mcr.microsoft.com/aci/msi-atlas-adapter:master_20201203.1 + args: ["test", "values"] + ports: + - containerPort: 80 + name: web +""" + @classmethod + def setUpClass(cls): + cls.key_dir_parent = os.path.join(TEST_DIR, '..', '..', '..', 'samples', 'certs') + cls.key = os.path.join(cls.key_dir_parent, 'intermediateCA', 'private', 'ec_p384_private.pem') + cls.chain = os.path.join(cls.key_dir_parent, 'intermediateCA', 'certs', 'www.contoso.com.chain.cert.pem') + if not os.path.exists(cls.key) or not os.path.exists(cls.chain): + script_path = os.path.join(cls.key_dir_parent, 'create_certchain.sh') + + arg_list = [ + script_path, + ] + os.chmod(script_path, 0o755) + + # NOTE: this will raise an exception if it's run on windows and the key/cert files don't exist + item = subprocess.run( + arg_list, + check=False, + shell=True, + cwd=cls.key_dir_parent, + env=os.environ.copy(), + ) + + if item.returncode != 0: + raise Exception("Error creating certificate chain") + def test_compare_policy_sources(self): custom_policy = load_policy_from_str(self.custom_json) custom_policy.populate_policy_content_for_all_images() @@ -288,6 +381,59 @@ def test_compare_policy_sources(self): # test the image command self.assertEqual(custom_containers[0].get("command"), virtual_node_containers[0].get("command")) + + def test_virtual_node_policy_fragments(self): + try: + fragment_filename = "policy_file.json" + yaml_filename = "policy_file.yaml" + os_util.write_str_to_file(fragment_filename, self.custom_json2) + os_util.write_str_to_file(yaml_filename, self.custom_yaml) + rego_filename = "example_file" + acifragmentgen_confcom(None, fragment_filename, None, rego_filename, "1", "test_feed_file", self.key, self.chain, None) + + # create import file + import_filename = "my_fragments.json" + signed_file_path = f"{rego_filename}.rego.cose" + acifragmentgen_confcom(None, None, None, None, None, None, None, None, "1", fragment_path=signed_file_path, generate_import=True, fragments_json=import_filename) + # add path into the fragment import + import_data = os_util.load_json_from_file(import_filename) + import_data[config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS][0][config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_PATH] = signed_file_path + os_util.write_json_to_file(import_filename, import_data) + + # create policy using import statement + acipolicygen_confcom(None, None, None, None, yaml_filename, None, None, fragments_json=import_filename, exclude_default_fragments=True, include_fragments=True) + + # count all the vn2 specific env vars to amke sure they're all there + fragment_content = os_util.str_to_base64(os_util.load_str_from_file(f"{rego_filename}.rego")) + containers, _ = decompose_confidential_properties(fragment_content) + + env_vars = containers[0].get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS) + + vn2_env_var_count = 0 + vn2_env_vars = [x.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS_NAME) for x in config.VIRTUAL_NODE_ENV_RULES] + + for env_var in env_vars: + name = env_var.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS_RULE).split("=")[0] + if name in vn2_env_vars: + vn2_env_var_count += 1 + self.assertEqual(len(vn2_env_vars), vn2_env_var_count) + + output_yaml = os_util.load_yaml_from_file(yaml_filename) + output_containers, output_fragments = decompose_confidential_properties(output_yaml.get(config.VIRTUAL_NODE_YAML_METADATA).get(config.VIRTUAL_NODE_YAML_ANNOTATIONS).get(config.VIRTUAL_NODE_YAML_POLICY)) + + self.assertTrue(config.DEFAULT_REGO_FRAGMENTS not in output_fragments) + for container in output_containers: + if container.get(config.POLICY_FIELD_CONTAINERS_NAME) == "simple-container": + self.fail("policy contains container covered by fragment") + finally: + + os_util.force_delete_silently(fragment_filename) + os_util.force_delete_silently(yaml_filename) + os_util.force_delete_silently(import_filename) + os_util.force_delete_silently(signed_file_path) + os_util.force_delete_silently(f"{rego_filename}.rego") + + def test_configmaps(self): virtual_node_policy = load_policy_from_virtual_node_yaml_str(self.custom_yaml_configmap)[0] virtual_node_policy.populate_policy_content_for_all_images() @@ -360,9 +506,12 @@ def test_workload_identity(self): # have to extract the name from the pattern env_rule_names = [(env_rule['pattern']).split("=")[0] for env_rule in containers[0][config.POLICY_FIELD_CONTAINERS_ELEMENTS_ENVS]] + mounts = [mount[config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS_DESTINATION] for mount in containers[0][config.ACI_FIELD_CONTAINERS_MOUNTS]] for var in config.VIRTUAL_NODE_ENV_RULES_WORKLOAD_IDENTITY: self.assertTrue(var['name'] in env_rule_names) + for mount in config.DEFAULT_MOUNTS_WORKLOAD_IDENTITY_VIRTUAL_NODE: + self.assertTrue(mount['mountPath'] in mounts) def test_volume_claim(self): virtual_node_policy = load_policy_from_virtual_node_yaml_str(self.custom_yaml_volume_claim)[0] @@ -380,4 +529,13 @@ def test_volume_claim(self): self.assertTrue("ro" in mount[config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS_OPTIONS]) # get the nginx mount and make sure it is readonly - containers[0][config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS] \ No newline at end of file + containers[0][config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS] + + def test_custom_args(self): + virtual_node_policy = load_policy_from_virtual_node_yaml_str(self.custom_yaml_command)[0] + virtual_node_policy.populate_policy_content_for_all_images() + container_start = "containers := " + containers = json.loads(extract_containers_from_text(virtual_node_policy.get_serialized_output(OutputType.PRETTY_PRINT), container_start)) + command = containers[0].get("command") + + self.assertEqual(command[-2:], ["test", "values"]) \ No newline at end of file diff --git a/src/confcom/setup.py b/src/confcom/setup.py index bf9add00293..c3de7dea809 100644 --- a/src/confcom/setup.py +++ b/src/confcom/setup.py @@ -19,7 +19,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") -VERSION = "1.2.2" +VERSION = "1.2.3" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers