Repository: Azure/azure-cli-extensions
Pull request #8570: [confcom] supporting OCI images and adding tests
Author: login: SethHollandsworth
Created: 2025-03-17T13:50:41Z | Merged: 2025-03-19T02:28:59Z

Supporting OCI images and adding tests.
---
Previously, fully OCI-formatted images would fail out if downloaded from tools like Skopeo or Oras. Now they can be processed and dmverity hashed in the same way as docker-generated images. This change also corresponds to a release in [dmverity-vhd](https://github.com/microsoft/integrity-vhd/releases/tag/v1.5) that fixes a bug where some image layers would return blank.

This PR also adds testing for signed image fragments.

This checklist is used to make sure that common guidelines for a pull request are followed.

### Related command
az confcom


### General Guidelines

- [x] Have you run `azdev style <YOUR_EXT>` locally? (`pip install azdev` required)
- [x] Have you run `python scripts/ci/test_index.py -q` locally? (`pip install wheel==0.30.0` required)
- [x] My extension version conforms to the [Extension version schema](https://github.com/Azure/azure-cli/blob/release/doc/extensions/versioning_guidelines.md)


### About Extension Publish

There is a pipeline to automatically build, upload and publish extension wheels.  
Once your pull request is merged into main branch, a new pull request will be created to update `src/index.json` automatically.  
You only need to update the version information in file setup.py and historical information in file HISTORY.rst in your PR but do not modify `src/index.json`. 



Diff:
diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst
index eff93382642..cd3fac513a9 100644
--- a/src/confcom/HISTORY.rst
+++ b/src/confcom/HISTORY.rst
@@ -2,6 +2,14 @@
 
 Release History
 ===============
+1.2.2
+++++++
+* support for pure OCI v1 schema 2 formatted images
+* adding debug logging
+* changing where parameters and variables are filled in for arm templates
+* updating documentation about fragments
+* bugfix for exec processes in fragment generation
+* bugfix for custom mount options in fragment generation
 
 1.2.1
 ++++++
diff --git a/src/confcom/azext_confcom/README.md b/src/confcom/azext_confcom/README.md
index 5b76f41ca1f..96da3dca571 100644
--- a/src/confcom/azext_confcom/README.md
+++ b/src/confcom/azext_confcom/README.md
@@ -30,6 +30,8 @@
     - [allow_unencrypted_scratch](#allow_unencrypted_scratch)
     - [allow_capabilities_dropping](#allow_capabilities_dropping)
 - [Microsoft Azure CLI 'confcom acifragmentgen' Extension Examples](#microsoft-azure-cli-confcom-acifragmentgen-extension-examples)
+  - [Types of Policy Fragments](#types-of-policy-fragments)
+  - [Examples](#examples)
 - [Microsoft Azure CLI 'confcom katapolicygen' Extension Examples](#microsoft-azure-cli-confcom-katapolicygen-extension-examples)
 
 ## Microsoft Azure CLI 'confcom acipolicygen' Extension Examples
@@ -192,6 +194,8 @@ Use the following command to generate CCE policy for the image.
 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:
 
 ```bash
@@ -665,6 +669,15 @@ Run `az confcom acifragmentgen --help` to see a list of supported arguments alon
 
 For information on what a policy fragment is, see [policy fragments](#policy-fragments). For a full walkthrough on how to generate a policy fragment and use it in a policy, see [Create a Key and Cert for Signing](../samples/certs/README.md).
 
+### Types of Policy Fragments
+
+There are two types of policy fragments:
+
+1. Image-attached fragments: These are fragments that are attached to an image in an ORAS-compliant registry. They are used to provide additional security information about the image and are to be used for a single image. Image-attached fragments are currently in development. Note that nested image-attached fragments are *not* supported.
+2. Standalone fragments: These are fragments that are uploaded to an ORAS-compliant registry independent of a specific image and can be used for multiple images. Standalone fragments are currently not supported. Once implemented, nested standalone fragments will be supported.
+
+### Examples
+
 **Examples:**
 
 Example 1: The following command creates a security fragment and prints it to stdout as well as saving it to a file `contoso.rego`:
diff --git a/src/confcom/azext_confcom/cose_proxy.py b/src/confcom/azext_confcom/cose_proxy.py
index a02d4e8ba95..e026219398a 100644
--- a/src/confcom/azext_confcom/cose_proxy.py
+++ b/src/confcom/azext_confcom/cose_proxy.py
@@ -9,6 +9,7 @@
 import platform
 from typing import List
 import requests
+from knack.log import get_logger
 from azext_confcom.errors import eprint
 from azext_confcom.config import (
     REGO_CONTAINER_START,
@@ -21,7 +22,7 @@
     ACI_FIELD_CONTAINERS_REGO_FRAGMENTS_INCLUDES,
 )
 
-
+logger = get_logger(__name__)
 host_os = platform.system()
 machine = platform.machine()
 
@@ -128,7 +129,7 @@ def cose_sign(
 
         if iss:
             arg_list.extend(["-issuer", iss])
-
+        logger.info("Signing the policy fragment: %s", out_path)
         call_cose_sign_tool(arg_list, "Error signing the policy fragment")
         return True
 
@@ -150,7 +151,7 @@ def generate_import_from_path(self, fragment_path: str, minimum_svn: int) -> str
         policy_bin_str = str(self.policy_bin)
 
         arg_list_chain = [policy_bin_str, "check", "--in", fragment_path, "--verbose"]
-
+        logger.info("Extracting import statement from signed fragment: %s", fragment_path)
         item = call_cose_sign_tool(arg_list_chain, "Error getting information from signed fragment file")
 
         stdout = item.stdout.decode("utf-8")
@@ -182,7 +183,7 @@ def extract_payload_from_path(self, fragment_path: str) -> str:
             eprint(f"The fragment file at {fragment_path} does not exist")
 
         arg_list_chain = [policy_bin_str, "check", "--in", fragment_path, "--verbose"]
-
+        logger.info("Extracting payload from signed fragment: %s", fragment_path)
         item = call_cose_sign_tool(arg_list_chain, "Error getting information from signed fragment file")
 
         stdout = item.stdout.decode("utf-8")
@@ -194,7 +195,7 @@ def extract_feed_from_path(self, fragment_path: str) -> str:
             eprint(f"The fragment file at {fragment_path} does not exist")
 
         arg_list_chain = [policy_bin_str, "check", "--in", fragment_path, "--verbose"]
-
+        logger.info("Extracting feed from signed fragment: %s", fragment_path)
         item = call_cose_sign_tool(arg_list_chain, "Error getting information from signed fragment file")
 
         stdout = item.stdout.decode("utf-8")
diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py
index 89d727f4f21..3cbf3f764c7 100644
--- a/src/confcom/azext_confcom/custom.py
+++ b/src/confcom/azext_confcom/custom.py
@@ -97,7 +97,8 @@ def acipolicygen_confcom(
     # gather information about the fragments being used in the new policy
     if include_fragments:
         fragments_list = os_util.load_json_from_file(fragments_json or input_path)
-        fragments_list = fragments_list.get("fragments", []) or fragments_list
+        if isinstance(fragments_list, dict):
+            fragments_list = fragments_list.get("fragments", [])
 
         # convert to list if it's just a dict
         if not isinstance(fragments_list, list):
@@ -158,6 +159,7 @@ def acipolicygen_confcom(
     # and associate them with each container group
 
     if include_fragments:
+        logger.info("Including fragments in the policy")
         fragment_policy_list = []
         container_names = []
         fragment_imports = []
@@ -165,11 +167,13 @@ def acipolicygen_confcom(
             fragment_imports.extend(policy.get_fragments())
             for container in policy.get_images():
                 container_names.append(container.get_container_image())
+        # get all the fragments that are being used in the policy
         fragment_policy_list = get_all_fragment_contents(container_names, fragment_imports)
         for policy in container_group_policies:
             policy.set_fragment_contents(fragment_policy_list)
 
     for count, policy in enumerate(container_group_policies):
+        # this is where parameters and variables are populated
         policy.populate_policy_content_for_all_images(
             individual_image=bool(image_name), tar_mapping=tar_mapping, faster_hashing=faster_hashing
         )
diff --git a/src/confcom/azext_confcom/data/internal_config.json b/src/confcom/azext_confcom/data/internal_config.json
index a89c35447d6..e9aa17a5b3b 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.1",
+    "version": "1.2.2",
     "hcsshim_config": {
         "maxVersion": "1.0.0",
         "minVersion": "0.0.1"
diff --git a/src/confcom/azext_confcom/oras_proxy.py b/src/confcom/azext_confcom/oras_proxy.py
index c232a46d3f3..0622c1456b2 100644
--- a/src/confcom/azext_confcom/oras_proxy.py
+++ b/src/confcom/azext_confcom/oras_proxy.py
@@ -7,6 +7,7 @@
 import json
 import platform
 import re
+from knack.log import get_logger
 from typing import List
 from azext_confcom.errors import eprint
 from azext_confcom.config import ARTIFACT_TYPE
@@ -16,6 +17,37 @@
 host_os = platform.system()
 machine = platform.machine()
 
+logger = get_logger(__name__)
+
+
+def prepend_docker_registry(image_name: str) -> str:
+    """
+    Normalize a Docker image reference by adding `docker.io/library` if necessary.
+
+    Args:
+        image (str): The Docker image reference (e.g., `nginx:latest` or `myrepo/myimage`).
+
+    Returns:
+        str: The normalized Docker image reference.
+    """
+    # Split the image into name and tag
+    if ":" in image_name:
+        name, _ = image_name.rsplit(":", 1)
+    else:
+        name, _ = image_name, "latest"
+
+    registry = ""
+    # Check if the image name contains a registry (e.g., docker.io, custom registry)
+    if "/" not in name or "." not in name.split("/")[0]:
+        # If no registry is specified, assume docker.io/library
+        if "/" not in name:
+            # Add the `library` namespace for official images
+            registry = "library/"
+        # Add the default `docker.io` registry
+        registry = f"docker.io/{registry}"
+
+    return f"{registry}{image_name}"
+
 
 def call_oras_cli(args, check=False):
     return subprocess.run(args, check=check, capture_output=True, timeout=120)
@@ -26,10 +58,14 @@ def call_oras_cli(args, check=False):
 def discover(
     image: str,
 ) -> List[str]:
+    # normalize the name in case the docker registry is implied
+    image = prepend_docker_registry(image)
+
     arg_list = ["oras", "discover", image, "-o", "json", "--artifact-type", ARTIFACT_TYPE]
     item = call_oras_cli(arg_list, check=False)
     hashes = []
 
+    logger.info("Discovering fragments for %s: %s", image, item.stdout.decode('utf-8'))
     if item.returncode == 0:
         json_output = json.loads(item.stdout.decode("utf-8"))
         manifests = json_output.get("manifests", [])
@@ -55,6 +91,7 @@ def pull(
     if "@sha256:" in image:
         image = image.split("@")[0]
     arg_list = ["oras", "pull", f"{image}@{image_hash}"]
+    logger.info("Pulling fragment: %s@%s", image, image_hash)
     item = call_oras_cli(arg_list, check=False)
 
     # get the exit code from the subprocess
diff --git a/src/confcom/azext_confcom/os_util.py b/src/confcom/azext_confcom/os_util.py
index 0671d0556d2..36b2d49cb07 100644
--- a/src/confcom/azext_confcom/os_util.py
+++ b/src/confcom/azext_confcom/os_util.py
@@ -11,6 +11,7 @@
 import shutil
 import json
 import os
+import stat
 from tarfile import TarFile
 from azext_confcom.errors import (
     eprint,
@@ -170,7 +171,7 @@ def map_image_from_tar_backwards_compatibility(image_name: str, tar: TarFile, ta
                 ][0]
                 break
         # remove the extracted manifest file to clean up
-        os.remove(manifest_path)
+        force_delete_silently(manifest_path)
     else:
         eprint(f"Tarball at {tar_location} contains no images")
 
@@ -182,14 +183,64 @@ def map_image_from_tar_backwards_compatibility(image_name: str, tar: TarFile, ta
     image_info_file_path = os.path.join(tar_dir, info_file.name)
     image_info_raw = load_json_from_file(image_info_file_path)
     # delete the extracted json file to clean up
-    os.remove(image_info_file_path)
+    force_delete_silently(image_info_file_path)
     image_info = image_info_raw.get("config")
     # importing the constant from config.py gives a circular dependency error
     image_info["Architecture"] = image_info_raw.get("architecture")
 
+    shutil.rmtree("blobs", ignore_errors=True)
     return image_info
 
 
+def get_oci_image_name(image_name: str) -> str:
+    if "/" not in image_name:
+        return f"docker.io/library/{image_name}"
+    return image_name
+
+
+def read_file_from_tar(tar: TarFile, filename: str) -> str:
+    try:
+        return tar.extractfile(filename).read()
+    except KeyError:
+        eprint(f"'{filename}' not found in tar file")
+
+
+def map_image_from_tar_oci_layout_v1(image_name: str, tar: TarFile, tar_location: str):
+    # since this uses containerd naming, we need to append the docker.io path
+    oci_image_name = get_oci_image_name(image_name)
+
+    index_bytes = read_file_from_tar(tar, "index.json")
+    index = load_json_from_str(index_bytes)
+
+    manifests = index.get("manifests") or []
+    for manifest in manifests:
+        image_annotations = manifest.get("annotations")
+        image_name_annotation = ""
+        if image_annotations:
+            image_name_annotation = image_annotations.get("io.containerd.image.name")
+            if image_name_annotation and image_name_annotation != oci_image_name:
+                continue
+        if (
+            manifest.get("mediaType") in
+            ["application/vnd.docker.distribution.manifest.v2+json", "application/vnd.oci.image.manifest.v1+json"]
+        ):
+            hashing_algo, manifest_name = manifest.get("digest").split(":")
+            manifest_location = f"blobs/{hashing_algo}/{manifest_name}"
+
+            nested_manifest_bytes = tar.extractfile(manifest_location).read()
+            nested_manifest = load_json_from_str(nested_manifest_bytes)
+            config = nested_manifest.get("config")
+
+            config_hashing_algo, config_digest = config.get("digest").split(":")
+            config_location = f"blobs/{config_hashing_algo}/{config_digest}"
+            image_info_raw_bytes = tar.extractfile(config_location).read()
+            image_info_raw = load_json_from_str(image_info_raw_bytes)
+            image_info = image_info_raw.get("config")
+            image_info["Architecture"] = image_info_raw.get("architecture")
+            return image_info
+    eprint(f"Image '{image_name}' is not found in '{tar_location}'")
+
+
 def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
     tar_dir = os.path.dirname(tar_location)
     info_file = None
@@ -209,7 +260,7 @@ def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
                 break
     finally:
         # remove the extracted manifest file to clean up
-        os.remove(manifest_path)
+        force_delete_silently(manifest_path)
 
     if not info_file:
         return None
@@ -219,7 +270,7 @@ def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
     image_info_file_path = os.path.join(tar_dir, info_file)
     image_info_raw = load_json_from_file(image_info_file_path)
     # delete the extracted json file to clean up
-    os.remove(image_info_file_path)
+    force_delete_silently(image_info_file_path)
     image_info = image_info_raw.get("config")
     # importing the constant from config.py gives a circular dependency error
     image_info["Architecture"] = image_info_raw.get("architecture")
@@ -227,6 +278,19 @@ def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
     return image_info
 
 
+# sometimes image tarfiles have readonly members. this will try to change their permissions and delete them
+def force_delete_silently(filename: str) -> None:
+    try:
+        os.chmod(filename, stat.S_IWRITE)
+    except FileNotFoundError:
+        pass
+    except PermissionError:
+        eprint(f"Permission denied to edit file: {filename}")
+    except OSError as e:
+        eprint(f"Error editing file: {filename}, {e}")
+    delete_silently(filename)
+
+
 # helper function to delete a file that may or may not exist
 def delete_silently(filename: str) -> None:
     try:
diff --git a/src/confcom/azext_confcom/rootfs_proxy.py b/src/confcom/azext_confcom/rootfs_proxy.py
index fca3757f872..d9d7ba35f89 100644
--- a/src/confcom/azext_confcom/rootfs_proxy.py
+++ b/src/confcom/azext_confcom/rootfs_proxy.py
@@ -103,6 +103,7 @@ def get_policy_image_layers(
 
         # decide if we're reading from a tarball or not
         if tar_location:
+            logger.info("Calculating layer hashes from tarball")
             arg_list += ["--tarball", tar_location]
         else:
             arg_list += ["-d"]
diff --git a/src/confcom/azext_confcom/security_policy.py b/src/confcom/azext_confcom/security_policy.py
index 34dbb7eddc9..4fbf17f31ee 100644
--- a/src/confcom/azext_confcom/security_policy.py
+++ b/src/confcom/azext_confcom/security_policy.py
@@ -136,6 +136,7 @@ def __init__(
                 container_image = UserContainerImage.from_json(c, is_vn2=is_vn2)
             else:
                 container_image = ContainerImage.from_json(c)
+            container_image.parse_all_parameters_and_variables(self.all_params, self.all_vars)
             container_results.append(container_image)
 
         self._images = container_results
@@ -431,9 +432,8 @@ def populate_policy_content_for_all_images(
             message_queue = []
             # populate regular container images(s)
             for image in container_images:
-                image.parse_all_parameters_and_variables(AciPolicy.all_params, AciPolicy.all_vars)
                 image_name = f"{image.base}:{image.tag}"
-
+                logger.info("Processing image: %s", image_name)
                 image_info, tar = get_image_info(progress, message_queue, tar_mapping, image)
 
                 # verify and populate the working directory property
@@ -517,6 +517,7 @@ def populate_policy_content_for_all_images(
                             }
                         image.set_user(user)
 
+                # should have all fragments before this point
                 if self._fragment_contents and self.should_eliminate_container_covered_by_fragments(image):
                     # these containers will get taken out later in the function
                     # since they are covered by a fragment
@@ -694,8 +695,8 @@ def load_policy_from_arm_template_str(
             ] = infrastructure_svn
         if rego_imports:
             # error check the rego imports for invalid data types
-            process_fragment_imports(rego_imports)
-            rego_fragments.extend(rego_imports)
+            processed_imports = process_fragment_imports(rego_imports)
+            rego_fragments.extend(processed_imports)
 
         volumes = (
             case_insensitive_dict_get(
@@ -1273,7 +1274,15 @@ def load_policy_from_config_str(config_str, debug_mode: bool = False, disable_st
                 f'Field ["{config.ACI_FIELD_TEMPLATE_IMAGE}"] is empty or cannot be found'
             )
 
-        exec_processes = []
+        exec_processes = case_insensitive_dict_get(
+            container_properties, config.ACI_FIELD_CONTAINERS_EXEC_PROCESSES
+        ) or []
+
+        # add the signal section if it's not present
+        for exec_process in exec_processes:
+            if config.ACI_FIELD_CONTAINERS_SIGNAL_CONTAINER_PROCESSES not in exec_process:
+                exec_process[config.ACI_FIELD_CONTAINERS_SIGNAL_CONTAINER_PROCESSES] = []
+
         extract_probe(exec_processes, container_properties, config.ACI_FIELD_CONTAINERS_READINESS_PROBE)
         extract_probe(exec_processes, container_properties, config.ACI_FIELD_CONTAINERS_LIVENESS_PROBE)
 
@@ -1289,7 +1298,8 @@ 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),
+                config.ACI_FIELD_CONTAINERS_MOUNTS: process_mounts_from_config(container_properties)
+                + process_configmap(container_properties),
                 config.ACI_FIELD_CONTAINERS_EXEC_PROCESSES: exec_processes
                 + config.DEBUG_MODE_SETTINGS.get("execProcesses")
                 if debug_mode
diff --git a/src/confcom/azext_confcom/template_util.py b/src/confcom/azext_confcom/template_util.py
index b28424de3a0..d8b25a400e3 100644
--- a/src/confcom/azext_confcom/template_util.py
+++ b/src/confcom/azext_confcom/template_util.py
@@ -13,12 +13,15 @@
 import deepdiff
 import yaml
 import docker
+from knack.log import get_logger
 from azext_confcom.errors import (
     eprint,
 )
 from azext_confcom import os_util
 from azext_confcom import config
 
+logger = get_logger(__name__)
+
 # TODO: these can be optimized to not have so many groups in the single match
 # make this global so it can be used in multiple functions
 PARAMETER_AND_VARIABLE_REGEX = r"\[(?:parameters|variables)\(\s*'([^\.\/]+?)'\s*\)\]"
@@ -101,16 +104,22 @@ def get_image_info(progress, message_queue, tar_mapping, image):
         tar_location = get_tar_location_from_mapping(tar_mapping, image_name)
         # if we have a tar location, we can try to get the image info
         if tar_location:
-            with tarfile.open(tar_location) as tar:
+            with tarfile.open(tar_location) as tar_file:
                 # get all the info out of the tarfile
                 try:
+                    logger.info("using backwards compatibility tar file")
                     image_info = os_util.map_image_from_tar_backwards_compatibility(
-                        image_name, tar, tar_location
+                        image_name, tar_file, tar_location
                     )
                 except IndexError:
+                    logger.info("using docker formatted tar file")
                     image_info = os_util.map_image_from_tar(
-                        image_name, tar, tar_location
+                        image_name, tar_file, tar_location
                     )
+                except (KeyError, AttributeError):
+                    # manifest.json not found
+                    logger.info("using OCI tar file")
+                    image_info = os_util.map_image_from_tar_oci_layout_v1(image_name, tar_file, tar_location)
                 if image_info is not None:
                     tar = True
                     message_queue.append(f"{image_name} read from local tar file")
@@ -174,6 +183,7 @@ def get_image_info(progress, message_queue, tar_mapping, image):
             f"{raw_image.attrs.get(config.ACI_FIELD_CONTAINERS_ARCHITECTURE_KEY)}. "
             f"Only {config.ACI_FIELD_CONTAINERS_ARCHITECTURE_VALUE} is supported by Confidential ACI"
         ))
+
     return image_info, tar
 
 
@@ -228,6 +238,7 @@ def process_env_vars_from_template(params: dict,
                     response = approve_wildcards or input(
                         f'Create a wildcard policy for the environment variable {name} (y/n): ')
                     if approve_wildcards or response.lower() == 'y':
+                        logger.info('Creating a wildcard policy for the environment variable %s', name)
                         env_vars.append({
                             config.ACI_FIELD_CONTAINERS_ENVS_NAME: name,
                             config.ACI_FIELD_CONTAINERS_ENVS_VALUE: ".*",
@@ -509,6 +520,8 @@ def process_env_vars_from_config(container) -> List[Dict[str, str]]:
 
 def process_fragment_imports(rego_imports) -> None:
     for rego_import in rego_imports:
+        if not rego_import:
+            continue
         feed = case_insensitive_dict_get(
             rego_import, config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED
         )
@@ -550,6 +563,8 @@ def process_fragment_imports(rego_imports) -> None:
                 + "can only be a list value."
             )
 
+    return rego_imports
+
 
 def process_mounts(image_properties: dict, volumes: List[dict]) -> List[Dict[str, str]]:
     mount_source_table_keys = config.MOUNT_SOURCE_TABLE.keys()
@@ -669,6 +684,9 @@ def process_mounts_from_config(image_properties: dict) -> List[Dict[str, str]]:
                 config.ACI_FIELD_CONTAINERS_MOUNTS_READONLY: case_insensitive_dict_get(
                     mount, config.ACI_FIELD_TEMPLATE_MOUNTS_READONLY
                 ),
+                config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS_OPTIONS: case_insensitive_dict_get(
+                    mount, config.POLICY_FIELD_CONTAINERS_ELEMENTS_MOUNTS_OPTIONS
+                )
             }
         )
     return mounts
@@ -781,10 +799,13 @@ def readable_diff(diff_dict) -> Dict[str, Any]:
             # search for the area of the ARM Template with the change i.e. "mounts" or "env_rules"
             for key in diff_dict[category]:
                 key = str(key)
-                key_name = re.search(r"'(.*?)'", key).group(1)
-                human_readable_diff[new_name].setdefault(key_name, []).append(
-                    diff_dict[category][key]
-                )
+
+                key_name_group = re.search(r"'(.*?)'", key)
+                if key_name_group is not None:
+                    key_name = key_name_group.group(1)
+                    human_readable_diff[new_name].setdefault(key_name, []).append(
+                        diff_dict[category][key]
+                    )
 
     return change_key_names(human_readable_diff)
 
diff --git a/src/confcom/azext_confcom/tests/latest/README.md b/src/confcom/azext_confcom/tests/latest/README.md
index 82d4e8ac7e4..e15a5b2f2f9 100644
--- a/src/confcom/azext_confcom/tests/latest/README.md
+++ b/src/confcom/azext_confcom/tests/latest/README.md
@@ -164,4 +164,7 @@ test_debug_processes | mcr.microsoft.com/cbl-mariner/distroless/minimal:2.0 | En
 test_fragment_sidecar | mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 | See if sidecar fragments can be created by a given policy.json
 test_fragment_sidecar_stdio_access_default | mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 | Check that sidecar containers have std I/O access by default
 test_fragment_incorrect_sidecar | mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 | See what output format for failing sidecar validation would be
+test_signing | mcr.microsoft.com/acc/samples/aci/helloworld:2.8 | Sign a fragment with a key and chain file
+test_generate_import | mcr.microsoft.com/acc/samples/aci/helloworld:2.8 | Generate an import statement for the signed fragment file
+test_local_fragment_references | mcr.microsoft.com/acc/samples/aci/helloworld:2.8 | Make sure the fragment references are correct when the fragment is local
 test_invalid_input | mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 | Fail out under various invalid input circumstances
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 8589548f38f..862d6377f1d 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,7 @@
 import os
 import unittest
 import json
-import errno
+import time
 import subprocess
 from knack.util import CLIError
 
@@ -647,27 +647,28 @@ class FragmentPolicySigning(unittest.TestCase):
     """
     @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")
+        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")
 
         with load_policy_from_config_str(cls.custom_json) as aci_policy:
             aci_policy.populate_policy_content_for_all_images()
@@ -676,115 +677,115 @@ def setUpClass(cls):
             aci_policy2.populate_policy_content_for_all_images()
             cls.aci_policy2 = aci_policy2
 
-    # def test_signing(self):
-    #     filename = "payload.rego"
-    #     feed = "test_feed"
-    #     algo = "ES384"
-    #     out_path = filename + ".cose"
-
-    #     fragment_text = self.aci_policy.generate_fragment("payload", 1, OutputType.RAW)
-    #     try:
-    #         write_str_to_file(filename, fragment_text)
-
-    #         cose_proxy = CoseSignToolProxy()
-    #         iss = cose_proxy.create_issuer(self.chain)
-
-    #         cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path)
-    #         self.assertTrue(os.path.exists(filename))
-    #         self.assertTrue(os.path.exists(out_path))
-    #     except Exception as e:
-    #         raise e
-    #     finally:
-    #         delete_silently(filename)
-    #         delete_silently(out_path)
-
-    # def test_generate_import(self):
-    #     filename = "payload4.rego"
-    #     feed = "test_feed"
-    #     algo = "ES384"
-    #     out_path = filename + ".cose"
-
-    #     fragment_text = self.aci_policy.generate_fragment("payload4", 1, OutputType.RAW)
-    #     try:
-    #         write_str_to_file(filename, fragment_text)
-
-    #         cose_proxy = CoseSignToolProxy()
-    #         iss = cose_proxy.create_issuer(self.chain)
-    #         cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path)
-
-    #         import_statement = cose_proxy.generate_import_from_path(out_path, 1)
-    #         self.assertTrue(import_statement)
-    #         self.assertEquals(
-    #             import_statement.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_ISSUER,""),iss
-    #         )
-    #         self.assertEquals(
-    #             import_statement.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED,""),feed
-    #         )
-    #         self.assertEquals(
-    #             import_statement.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN,""),1
-    #         )
-    #         self.assertEquals(
-    #             import_statement.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_INCLUDES,[]),[config.POLICY_FIELD_CONTAINERS, config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS]
-    #         )
-
-    #     except Exception as e:
-    #         raise e
-    #     finally:
-    #         delete_silently(filename)
-    #         delete_silently(out_path)
-
-    # def test_local_fragment_references(self):
-    #     filename = "payload2.rego"
-    #     filename2 = "payload3.rego"
-    #     fragment_json = "fragment.json"
-    #     feed = "test_feed"
-    #     feed2 = "test_feed2"
-    #     algo = "ES384"
-    #     out_path = filename + ".cose"
-    #     out_path2 = filename2 + ".cose"
-
-    #     fragment_text = self.aci_policy.generate_fragment("payload2", 1, OutputType.RAW)
-
-    #     try:
-    #         write_str_to_file(filename, fragment_text)
-    #         write_str_to_file(fragment_json, self.custom_json2)
-
-    #         cose_proxy = CoseSignToolProxy()
-    #         iss = cose_proxy.create_issuer(self.chain)
-    #         cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path)
-
-    #         # this will insert the import statement from the first fragment into the second one
-    #         acifragmentgen_confcom(
-    #             None, None, None, None, None, None, None, None, generate_import=True, minimum_svn=1, fragments_json=fragment_json, fragment_path=out_path
-    #         )
-    #         # put the "path" field into the import statement
-    #         temp_json = load_json_from_file(fragment_json)
-    #         temp_json["fragments"][0]["path"] = out_path
-
-    #         write_str_to_file(fragment_json, json.dumps(temp_json))
-
-    #         acifragmentgen_confcom(
-    #             None, fragment_json, None, "payload3", 1, feed2, self.key, self.chain, None, output_filename=filename2
-    #         )
-
-    #         # make sure all of our output files exist
-    #         self.assertTrue(os.path.exists(filename2))
-    #         self.assertTrue(os.path.exists(out_path2))
-    #         self.assertTrue(os.path.exists(fragment_json))
-    #         # check the contents of the unsigned rego file
-    #         rego_str = load_str_from_file(filename2)
-    #         # see if the import statement is in the rego file
-    #         self.assertTrue("test_feed" in rego_str)
-    #         # make sure the image covered by the first fragment isn't in the second fragment
-    #         self.assertFalse("mcr.microsoft.com/acc/samples/aci/helloworld:2.8" in rego_str)
-    #     except Exception as e:
-        #     raise e
-        # finally:
-        #     delete_silently(filename)
-        #     delete_silently(out_path)
-        #     delete_silently(filename2)
-        #     delete_silently(out_path2)
-        #     delete_silently(fragment_json)
+    def test_signing(self):
+        filename = "payload.rego"
+        feed = "test_feed"
+        algo = "ES384"
+        out_path = filename + ".cose"
+
+        fragment_text = self.aci_policy.generate_fragment("payload", 1, OutputType.RAW)
+        try:
+            write_str_to_file(filename, fragment_text)
+
+            cose_proxy = CoseSignToolProxy()
+            iss = cose_proxy.create_issuer(self.chain)
+
+            cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path)
+            self.assertTrue(os.path.exists(filename))
+            self.assertTrue(os.path.exists(out_path))
+        except Exception as e:
+            raise e
+        finally:
+            delete_silently(filename)
+            delete_silently(out_path)
+
+    def test_generate_import(self):
+        filename = "payload4.rego"
+        feed = "test_feed"
+        algo = "ES384"
+        out_path = filename + ".cose"
+
+        fragment_text = self.aci_policy.generate_fragment("payload4", 1, OutputType.RAW)
+        try:
+            write_str_to_file(filename, fragment_text)
+
+            cose_proxy = CoseSignToolProxy()
+            iss = cose_proxy.create_issuer(self.chain)
+            cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path)
+
+            import_statement = cose_proxy.generate_import_from_path(out_path, 1)
+            self.assertTrue(import_statement)
+            self.assertEqual(
+                import_statement.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_ISSUER,""),iss
+            )
+            self.assertEqual(
+                import_statement.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED,""),feed
+            )
+            self.assertEqual(
+                import_statement.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN,""),1
+            )
+            self.assertEqual(
+                import_statement.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_INCLUDES,[]),[config.POLICY_FIELD_CONTAINERS, config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS]
+            )
+
+        except Exception as e:
+            raise e
+        finally:
+            delete_silently(filename)
+            delete_silently(out_path)
+
+    def test_local_fragment_references(self):
+        filename = "payload2.rego"
+        filename2 = "payload3.rego"
+        fragment_json = "fragment.json"
+        feed = "test_feed"
+        feed2 = "test_feed2"
+        algo = "ES384"
+        out_path = filename + ".cose"
+        out_path2 = filename2 + ".cose"
+
+        fragment_text = self.aci_policy.generate_fragment("payload2", 1, OutputType.RAW)
+
+        try:
+            write_str_to_file(filename, fragment_text)
+            write_str_to_file(fragment_json, self.custom_json2)
+
+            cose_proxy = CoseSignToolProxy()
+            iss = cose_proxy.create_issuer(self.chain)
+            cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path)
+
+            # this will insert the import statement from the first fragment into the second one
+            acifragmentgen_confcom(
+                None, None, None, None, None, None, None, None, generate_import=True, minimum_svn=1, fragments_json=fragment_json, fragment_path=out_path
+            )
+            # put the "path" field into the import statement
+            temp_json = load_json_from_file(fragment_json)
+            temp_json["fragments"][0]["path"] = out_path
+
+            write_str_to_file(fragment_json, json.dumps(temp_json))
+
+            acifragmentgen_confcom(
+                None, fragment_json, None, "payload3", 1, feed2, self.key, self.chain, None, output_filename=filename2
+            )
+
+            # make sure all of our output files exist
+            self.assertTrue(os.path.exists(filename2))
+            self.assertTrue(os.path.exists(out_path2))
+            self.assertTrue(os.path.exists(fragment_json))
+            # check the contents of the unsigned rego file
+            rego_str = load_str_from_file(filename2)
+            # see if the import statement is in the rego file
+            self.assertTrue("test_feed" in rego_str)
+            # make sure the image covered by the first fragment isn't in the second fragment
+            self.assertFalse("mcr.microsoft.com/acc/samples/aci/helloworld:2.8" in rego_str)
+        except Exception as e:
+            raise e
+        finally:
+            delete_silently(filename)
+            delete_silently(out_path)
+            delete_silently(filename2)
+            delete_silently(out_path2)
+            delete_silently(fragment_json)
 
 class InitialFragmentErrors(ScenarioTest):
     def test_invalid_input(self):
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 fcea8fe86ea..1610c68c73b 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 = [
-                "7fdd8009ff6abf7a9d48808a507baecd5b815c8947fc03faa921b68a0ac260e8",
-                "280e8f98eb3a45ba7647d3df812236494188bfedb2c1fafa0ac2fb2f47930c0c"
+                "6ee0f2697647b69975229db7445260a1c9ae5b039f9a52a911e72b6c3302d391",
+                "3ba5dd39bf58f28b7e57a52d10edf1813fe3b1fd1d0ef57573b1b912d6b9d1f6"
             ]
             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 c26768e3705..f0cfe6f1e3f 100644
--- a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py
+++ b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py
@@ -7,11 +7,15 @@
 import unittest
 import deepdiff
 import json
+import shutil
+import tempfile
+from tarfile import TarFile
 
 from azext_confcom.security_policy import (
     OutputType,
     load_policy_from_arm_template_str,
 )
+from azext_confcom.rootfs_proxy import SecurityPolicyProxy
 from azext_confcom.errors import (
     AccContainerError,
 )
@@ -34,6 +38,190 @@ def remove_tar_file(image_path: str) -> None:
         os.remove(image_path)
 
 
+class PolicyGeneratingArmParametersCleanRoomOCITarFile(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls) -> None:
+        path = os.path.dirname(__file__)
+        cls.path = path
+
+    def test_oci_tar_file(self):
+        custom_arm_json_default_value = """
+    {
+        "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
+        "contentVersion": "1.0.0.0",
+
+
+        "parameters": {
+            "containergroupname": {
+                "type": "string",
+                "metadata": {
+                    "description": "Name for the container group"
+                },
+                "defaultValue":"simple-container-group"
+            },
+            "image": {
+                "type": "string",
+                "metadata": {
+                    "description": "Name for the container group"
+                },
+                "defaultValue":"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64"
+            },
+            "containername": {
+                "type": "string",
+                "metadata": {
+                    "description": "Name for the container"
+                },
+                "defaultValue":"simple-container"
+            },
+
+            "port": {
+                "type": "string",
+                "metadata": {
+                    "description": "Port to open on the container and the public IP address."
+                },
+                "defaultValue": "8080"
+            },
+            "cpuCores": {
+                "type": "string",
+                "metadata": {
+                    "description": "The number of CPU cores to allocate to the container."
+                },
+                "defaultValue": "1.0"
+            },
+            "memoryInGb": {
+                "type": "string",
+                "metadata": {
+                    "description": "The amount of memory to allocate to the container in gigabytes."
+                },
+                "defaultValue": "1.5"
+            },
+            "location": {
+                "type": "string",
+                "defaultValue": "[resourceGroup().location]",
+                "metadata": {
+                    "description": "Location for all resources."
+                }
+            }
+        },
+        "resources": [
+            {
+            "name": "[parameters('containergroupname')]",
+            "type": "Microsoft.ContainerInstance/containerGroups",
+            "apiVersion": "2023-05-01",
+            "location": "[parameters('location')]",
+
+            "properties": {
+                "containers": [
+                {
+                    "name": "[parameters('containername')]",
+                    "properties": {
+                    "image": "[parameters('image')]",
+                    "environmentVariables": [
+                        {
+                        "name": "PORT",
+                        "value": "80"
+                        }
+                    ],
+
+                    "ports": [
+                        {
+                        "port": "[parameters('port')]"
+                        }
+                    ],
+                    "command": [
+                        "/bin/bash",
+                        "-c",
+                        "while sleep 5; do cat /mnt/input/access.log; done"
+                    ],
+                    "resources": {
+                        "requests": {
+                        "cpu": "[parameters('cpuCores')]",
+                        "memoryInGb": "[parameters('memoryInGb')]"
+                        }
+                    }
+                    }
+                }
+                ],
+
+                "osType": "Linux",
+                "restartPolicy": "OnFailure",
+                "confidentialComputeProperties": {
+                "IsolationType": "SevSnp"
+                },
+                "ipAddress": {
+                "type": "Public",
+                "ports": [
+                    {
+                    "protocol": "Tcp",
+                    "port": "[parameters( 'port' )]"
+                    }
+                ]
+                }
+            }
+            }
+        ],
+        "outputs": {
+            "containerIPv4Address": {
+            "type": "string",
+            "value": "[reference(resourceId('Microsoft.ContainerInstance/containerGroups/', parameters('containergroupname'))).ipAddress.ip]"
+            }
+        }
+    }
+    """
+
+        regular_image = load_policy_from_arm_template_str(
+            custom_arm_json_default_value, ""
+        )[0]
+
+        regular_image.populate_policy_content_for_all_images()
+        SecurityPolicyProxy.layer_cache = {}
+        clean_room_image = load_policy_from_arm_template_str(
+            custom_arm_json_default_value, ""
+        )[0]
+
+        try:
+            with tempfile.TemporaryDirectory() as folder:
+                filename = os.path.join(folder, "oci.tar")
+
+                tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": os.path.join(self.path, "oci2.tar")}
+                create_tar_file(filename)
+                with TarFile(f"{folder}/oci.tar", "r") as tar:
+                    tar.extractall(path=folder)
+
+                os.remove(os.path.join(folder, "manifest.json"))
+                os.remove(os.path.join(folder, "oci.tar"))
+
+                with TarFile.open(os.path.join(self.path, "oci2.tar"), mode="w") as out_tar:
+                    out_tar.add(os.path.join(folder, "index.json"), "index.json")
+                    out_tar.add(os.path.join(folder, "blobs"), "blobs", recursive=True)
+
+                clean_room_image.populate_policy_content_for_all_images(
+                    tar_mapping=tar_mapping_file
+                )
+        except Exception as e:
+            print(e)
+            raise AccContainerError("Could not get image from tar file")
+        finally:
+            remove_tar_file(filename)
+            remove_tar_file(os.path.join(self.path, "oci2.tar"))
+
+        regular_image_json = json.loads(
+            regular_image.get_serialized_output(output_type=OutputType.RAW, rego_boilerplate=False)
+        )
+
+        clean_room_json = json.loads(
+            clean_room_image.get_serialized_output(output_type=OutputType.RAW, rego_boilerplate=False)
+        )
+
+        regular_image_json[0].pop(config.POLICY_FIELD_CONTAINERS_ID)
+        clean_room_json[0].pop(config.POLICY_FIELD_CONTAINERS_ID)
+
+        # see if the remote image and the local one produce the same output
+        self.assertEqual(
+            deepdiff.DeepDiff(regular_image_json, clean_room_json, ignore_order=True),
+            {},
+        )
+
 class PolicyGeneratingArmParametersCleanRoomTarFile(unittest.TestCase):
     @classmethod
     def setUpClass(cls) -> None:
@@ -170,13 +358,13 @@ def test_arm_template_with_parameter_file_clean_room_tar(self):
         )[0]
 
         regular_image.populate_policy_content_for_all_images()
-
+        SecurityPolicyProxy.layer_cache = {}
         clean_room_image = load_policy_from_arm_template_str(
             custom_arm_json_default_value, ""
         )[0]
 
         try:
-            filename = os.path.join(self.path, "./mariner.tar")
+            filename = os.path.join(self.path, "mariner.tar")
             tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename}
             create_tar_file(filename)
             clean_room_image.populate_policy_content_for_all_images(
@@ -376,7 +564,7 @@ def test_arm_template_mixed_mode_tar(self):
         )[0]
 
         regular_image.populate_policy_content_for_all_images()
-
+        SecurityPolicyProxy.layer_cache = {}
         clean_room_image = load_policy_from_arm_template_str(
             custom_arm_json_default_value, ""
         )[0]
@@ -400,7 +588,6 @@ def test_arm_template_mixed_mode_tar(self):
         regular_image_json[1].pop(config.POLICY_FIELD_CONTAINERS_ID)
         clean_room_json[1].pop(config.POLICY_FIELD_CONTAINERS_ID)
 
-        # see if the remote image and the local one produce the same output
         self.assertEqual(
             deepdiff.DeepDiff(regular_image_json, clean_room_json, ignore_order=True),
             {},
diff --git a/src/confcom/samples/certs/README.md b/src/confcom/samples/certs/README.md
index 248bb28d687..885e9f79f74 100644
--- a/src/confcom/samples/certs/README.md
+++ b/src/confcom/samples/certs/README.md
@@ -7,6 +7,10 @@
 - Must have the [`confcom` extension version 1.1.0 or greater](../../README.md) installed
 - Must have [ORAS CLI](https://oras.land/docs/installation/) installed (tested with version 1.1.0)
 
+## Notes
+
+The script `create_certchain.sh` will create the necessary certificates and private keys to sign a fragment policy. This script is used for testing purposes only and should not be used for production systems.
+
 ## Update Config
 
 *This step sets up the configuration for creating certs to sign the fragment policy. This only needs to be done once.*
diff --git a/src/confcom/samples/certs/create_certchain.sh b/src/confcom/samples/certs/create_certchain.sh
index 80cb7ed17d5..5e94f4c6f4e 100755
--- a/src/confcom/samples/certs/create_certchain.sh
+++ b/src/confcom/samples/certs/create_certchain.sh
@@ -20,6 +20,9 @@ echo 0100 > $RootPath/intermediateCA/crlnumber
 # create index files
 touch $RootPath/rootCA/index.txt
 touch $RootPath/intermediateCA/index.txt
+# NOTE: needed for testing
+echo "unique_subject = no" >> $RootPath/rootCA/index.txt.attr
+echo "unique_subject = no" >> $RootPath/intermediateCA/index.txt.attr
 
 # generate root key
 openssl genrsa -out $RootPath/rootCA/private/ca.key.pem 4096
diff --git a/src/confcom/setup.py b/src/confcom/setup.py
index 7c25e79eeca..bf9add00293 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.1"
+VERSION = "1.2.2"
 
 # The full list of classifiers is available at
 # https://pypi.python.org/pypi?%3Aaction=list_classifiers


Reviews:
user: login: hgarvison
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:52:31Z
user: login: SethHollandsworth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T13:17:27Z
user: login: hgarvison
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:03:06Z
user: login: kairu-ms
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T02:28:41Z

Review comments:
diff_hunk: @@ -219,14 +270,27 @@ def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
     image_info_file_path = os.path.join(tar_dir, info_file)
     image_info_raw = load_json_from_file(image_info_file_path)
     # delete the extracted json file to clean up
-    os.remove(image_info_file_path)
+    force_delete_silently(image_info_file_path)
     image_info = image_info_raw.get("config")
     # importing the constant from config.py gives a circular dependency error
     image_info["Architecture"] = image_info_raw.get("architecture")
 
     return image_info
 
 
+# sometimes image tarfiles have readonly members. this will try to change their permissions and delete them
+def force_delete_silently(filename: str) -> None:
+    try:
+        os.chmod(filename, stat.S_IWRITE)
+    except FileNotFoundError:
+        pass
path: src/confcom/azext_confcom/os_util.py
user: login: hgarvison
body: Should you have an error message here?
created_at: 2025-03-17T16:52:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -219,14 +270,27 @@ def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
     image_info_file_path = os.path.join(tar_dir, info_file)
     image_info_raw = load_json_from_file(image_info_file_path)
     # delete the extracted json file to clean up
-    os.remove(image_info_file_path)
+    force_delete_silently(image_info_file_path)
     image_info = image_info_raw.get("config")
     # importing the constant from config.py gives a circular dependency error
     image_info["Architecture"] = image_info_raw.get("architecture")
 
     return image_info
 
 
+# sometimes image tarfiles have readonly members. this will try to change their permissions and delete them
+def force_delete_silently(filename: str) -> None:
+    try:
+        os.chmod(filename, stat.S_IWRITE)
+    except FileNotFoundError:
+        pass
path: src/confcom/azext_confcom/os_util.py
user: login: SethHollandsworth
body: The idea is the file may or may not be there. we only want to delete it if it's there. Since the other errors might happen, I figured it it's a little cleaner to do it this way
created_at: 2025-03-18T13:17:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: azure-client-tools-bot-prd[bot]
created_at: 2025-03-17T13:50:45Z
body: <!-- Azure CLI Extensions Breaking Change Test --><details>
<summary>️✔️Azure CLI Extensions Breaking Change Test</summary>

><details>
> <summary>️✔️Non Breaking Changes</summary>
>
>
></details>
</details>

author_association: NONE
user: login: yonzhan
created_at: 2025-03-17T13:50:56Z
body: Thank you for your contribution! We will review the pull request and get back to you soon.
author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-03-17T13:50:57Z
body: <!-- add-pr-comment:gitHookNoticeComment -->

The git hooks are available for **azure-cli** and **azure-cli-extensions** repos. They could help you run required checks before creating the PR. 

Please sync the latest code with latest dev branch (for **azure-cli**) or main branch (for **azure-cli-extensions**). 
After that please run the following commands to enable git hooks:

```bash
pip install azdev --upgrade
azdev setup -c <your azure-cli repo path> -r <your azure-cli-extensions repo path>

```

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-17T13:54:34Z
body: <!-- add-pr-comment:versioncommentbot -->

 - For more info about extension versioning, please refer to [Extension version schema](https://github.com/Azure/azure-cli/blob/release/doc/extensions/versioning_guidelines.md)

author_association: CONTRIBUTOR
user: login: azclibot
created_at: 2025-03-19T02:37:09Z
body: [Release] Update index.json for extension [ confcom ] : https://dev.azure.com/msazure/One/_build/results?buildId=118403261&view=results
author_association: COLLABORATOR