From 50cb1403f35a66490ad0668aea0af1e2e1ce7ad6 Mon Sep 17 00:00:00 2001 From: Heather Garvison Date: Mon, 14 Jul 2025 16:06:49 -0400 Subject: [PATCH 1/5] ensure that oras discover doesn't error when the remote image doesn't exist --- src/confcom/HISTORY.rst | 5 + src/confcom/azext_confcom/README.md | 12 + src/confcom/azext_confcom/_help.py | 2 +- src/confcom/azext_confcom/_params.py | 3 +- src/confcom/azext_confcom/_validators.py | 4 +- src/confcom/azext_confcom/config.py | 2 + src/confcom/azext_confcom/cose_proxy.py | 9 +- src/confcom/azext_confcom/custom.py | 83 +- .../azext_confcom/data/internal_config.json | 2 +- src/confcom/azext_confcom/docs/README.md | 103 ++ .../azext_confcom/docs/acifragmentgen.md | 170 +++ .../azext_confcom/docs/acipolicygen.md | 392 +++++ src/confcom/azext_confcom/docs/common.md | 58 + .../azext_confcom/docs/katapolicygen.md | 45 + .../docs/policy_enforcement_points.md | 394 +++++ .../latest/README.md => docs/testing.md} | 6 + .../azext_confcom/docs/v1_json_file_format.md | 258 ++++ src/confcom/azext_confcom/fragment_util.py | 107 +- src/confcom/azext_confcom/oras_proxy.py | 196 ++- src/confcom/azext_confcom/os_util.py | 56 +- src/confcom/azext_confcom/security_policy.py | 104 +- src/confcom/azext_confcom/template_util.py | 102 +- .../tests/latest/test_confcom_fragment.py | 1348 ++++++++++++++++- .../tests/latest/test_confcom_kata.py | 2 - .../tests/latest/test_confcom_scenario.py | 18 +- .../tests/latest/test_confcom_tar.py | 50 +- .../tests/latest/test_confcom_virtual_node.py | 7 +- src/confcom/samples/certs/README.md | 2 +- src/confcom/setup.py | 2 +- 29 files changed, 3201 insertions(+), 341 deletions(-) create mode 100644 src/confcom/azext_confcom/docs/README.md create mode 100644 src/confcom/azext_confcom/docs/acifragmentgen.md create mode 100644 src/confcom/azext_confcom/docs/acipolicygen.md create mode 100644 src/confcom/azext_confcom/docs/common.md create mode 100644 src/confcom/azext_confcom/docs/katapolicygen.md create mode 100644 src/confcom/azext_confcom/docs/policy_enforcement_points.md rename src/confcom/azext_confcom/{tests/latest/README.md => docs/testing.md} (95%) create mode 100644 src/confcom/azext_confcom/docs/v1_json_file_format.md diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst index 3cb224ed589..fea04f2cd05 100644 --- a/src/confcom/HISTORY.rst +++ b/src/confcom/HISTORY.rst @@ -3,6 +3,11 @@ Release History =============== +1.2.7 +++++++ +* bugfix making it so that oras discover function doesn't error when no fragments are found in the remote repository +* splitting out documentation into command-specific files and adding info about --input flag + 1.2.6 ++++++ * bugfix making it so the fields in the --input format are case-insensitive diff --git a/src/confcom/azext_confcom/README.md b/src/confcom/azext_confcom/README.md index ba173e90365..e863ee93ee0 100644 --- a/src/confcom/azext_confcom/README.md +++ b/src/confcom/azext_confcom/README.md @@ -866,6 +866,18 @@ Using the same command, the default mounts and environment variables used by VN2 az confcom acifragmentgen --input ./fragment_config.json --svn 1 --namespace contoso ``` +Example 6: Create an import statement from a signed fragment in a remote repo: + +```bash +az confcom acifragmentgen --generate-import --fragment-path contoso.azurecr.io/:v1 --minimum-svn 1 +``` + +This is assuming there is a standalone fragment present at the specified location of `contoso.azurecr.io/:v1`. Fragment imports can also be created using local paths to signed fragment files such as: + +```bash +az confcom acifragmentgen --generate-import --fragment-path ./contoso.rego.cose --minimum-svn 1 +``` + ## Microsoft Azure CLI 'confcom katapolicygen' Extension Examples Run `az confcom katapolicygen --help` to see a list of supported arguments along with explanations. The following commands demonstrate the usage of different arguments to generate confidential computing security policies. diff --git a/src/confcom/azext_confcom/_help.py b/src/confcom/azext_confcom/_help.py index d884aa34e6c..15368cc61db 100644 --- a/src/confcom/azext_confcom/_help.py +++ b/src/confcom/azext_confcom/_help.py @@ -165,7 +165,7 @@ - name: --fragment-path -p type: string - short-summary: 'Path to an existing policy fragment file to be used with --generate-import. This option allows you to create import statements for the specified fragment without needing to pull it from an OCI registry' + short-summary: 'Path to an existing signed policy fragment file to be used with --generate-import. This option allows you to create import statements for the specified fragment without needing to explicitly pull it from an OCI registry. This can either be a local path or an OCI registry reference. For local fragments, the file will remain in the same location. For remote fragments, the file will be downloaded and cleaned up after processing' - name: --omit-id type: boolean diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index 1dc0d2e929d..855973176ac 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -172,7 +172,6 @@ def load_arguments(self, _): required=False, help="Omit the id field in the policy. This is helpful if the image being used will be present in multiple registries and used interchangeably.", ) - c.argument( "include_fragments", options_list=("--include-fragments", "-f"), @@ -266,7 +265,7 @@ def load_arguments(self, _): "fragment_path", options_list=("--fragment-path", "-p"), required=False, - help="Path to a policy fragment to be used with --generate-import to make import statements without having access to the fragment's OCI registry", + help="Path to a signed policy fragment to be used with --generate-import to make import statements without having access to the fragment's OCI registry. This can either be a local path or a registry address.", validator=validate_fragment_path, ) c.argument( diff --git a/src/confcom/azext_confcom/_validators.py b/src/confcom/azext_confcom/_validators.py index 0fd444c1486..d79a630bb6b 100644 --- a/src/confcom/azext_confcom/_validators.py +++ b/src/confcom/azext_confcom/_validators.py @@ -79,6 +79,8 @@ def validate_image_target(namespace): def validate_upload_fragment(namespace): if namespace.upload_fragment and not (namespace.key or namespace.chain): raise CLIError("Must sign the fragment with --key and --chain to upload it") + if namespace.upload_fragment and not (namespace.image_target or namespace.feed): + raise CLIError("Must either specify an --image-target or --feed to upload a fragment") def validate_fragment_generate_import(namespace): @@ -88,7 +90,7 @@ def validate_fragment_generate_import(namespace): ])) != 1: raise CLIError( ( - "Must provide either a fragment path, an input file, or " + "Must provide either a fragment path or " "an image name to generate an import statement" ) ) diff --git a/src/confcom/azext_confcom/config.py b/src/confcom/azext_confcom/config.py index 60e6a9f360a..7726d5c139b 100644 --- a/src/confcom/azext_confcom/config.py +++ b/src/confcom/azext_confcom/config.py @@ -72,6 +72,7 @@ ACI_FIELD_TEMPLATE_MOUNTS_READONLY = "readOnly" ACI_FIELD_TEMPLATE_CONFCOM_PROPERTIES = "confidentialComputeProperties" ACI_FIELD_TEMPLATE_CCE_POLICY = "ccePolicy" +ACI_FIELD_TEMPLATE_STANDALONE_REGO_FRAGMENTS = "standaloneFragments" ACI_FIELD_CONTAINERS_PRIVILEGED = "privileged" ACI_FIELD_CONTAINERS_CAPABILITIES = "capabilities" ACI_FIELD_CONTAINERS_CAPABILITIES_ADD = "add" @@ -169,6 +170,7 @@ ACI = "aci" KATA = "kata" +REGO_SVN_START = "svn := " CONFIG_FILE = "./data/internal_config.json" diff --git a/src/confcom/azext_confcom/cose_proxy.py b/src/confcom/azext_confcom/cose_proxy.py index 8278ccc6083..29779c46adf 100644 --- a/src/confcom/azext_confcom/cose_proxy.py +++ b/src/confcom/azext_confcom/cose_proxy.py @@ -154,6 +154,12 @@ def generate_import_from_path(self, fragment_path: str, minimum_svn: str) -> str item = call_cose_sign_tool(arg_list_chain, "Error getting information from signed fragment file") stdout = item.stdout.decode("utf-8") + # if we don't have a minimum svn, use the one from the fragment + fragment_svn = None + if minimum_svn == -1: + fragment_svn = stdout.split('svn := "')[1].split('"')[0] + if not fragment_svn: + eprint("Must have either a minimum SVN or fragment SVN defined") # extract issuer, feed, and payload from the fragment issuer = stdout.split("iss: ")[1].split("\n")[0] feed = stdout.split("feed: ")[1].split("\n")[0] @@ -170,7 +176,8 @@ def generate_import_from_path(self, fragment_path: str, minimum_svn: str) -> str import_statement = { POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_ISSUER: issuer, POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED: feed, - POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN: minimum_svn, + POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN: + minimum_svn if minimum_svn != -1 else fragment_svn, ACI_FIELD_CONTAINERS_REGO_FRAGMENTS_INCLUDES: includes, } diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index cac4f53f977..4405fefcc14 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -6,33 +6,22 @@ import os import sys -from pkg_resources import parse_version -from knack.log import get_logger +from azext_confcom import oras_proxy, os_util, security_policy from azext_confcom.config import ( - DEFAULT_REGO_FRAGMENTS, - POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS, - REGO_IMPORT_FILE_STRUCTURE, -) - -from azext_confcom import os_util -from azext_confcom.template_util import ( - pretty_print_func, - print_func, - str_to_sha256, - inject_policy_into_template, - inject_policy_into_yaml, - print_existing_policy_from_arm_template, - print_existing_policy_from_yaml, - get_image_name, -) + DEFAULT_REGO_FRAGMENTS, POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS, + REGO_IMPORT_FILE_STRUCTURE) +from azext_confcom.cose_proxy import CoseSignToolProxy +from azext_confcom.errors import eprint from azext_confcom.fragment_util import get_all_fragment_contents from azext_confcom.init_checks import run_initial_docker_checks -from azext_confcom import security_policy -from azext_confcom.security_policy import OutputType from azext_confcom.kata_proxy import KataPolicyGenProxy -from azext_confcom.cose_proxy import CoseSignToolProxy -from azext_confcom import oras_proxy - +from azext_confcom.security_policy import OutputType +from azext_confcom.template_util import ( + get_image_name, inject_policy_into_template, inject_policy_into_yaml, + pretty_print_func, print_existing_policy_from_arm_template, + print_existing_policy_from_yaml, print_func, str_to_sha256) +from knack.log import get_logger +from pkg_resources import parse_version logger = get_logger(__name__) @@ -96,12 +85,16 @@ def acipolicygen_confcom( fragments_list = [] # 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) - 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): + fragments_data = os_util.load_json_from_file(fragments_json or input_path) + if isinstance(fragments_data, dict): + fragments_list = fragments_data.get("fragments", []) + # standalone fragments from external file + fragments_list.extend(fragments_data.get("standaloneFragments", [])) + + # convert to list if it's just a dict. if it's empty, make it an empty list + if not fragments_data: + fragments_list = [] + elif not isinstance(fragments_list, list): fragments_list = [fragments_list] # telling the user what operation we're doing @@ -249,7 +242,15 @@ def acifragmentgen_confcom( import_statements = [] # images can have multiple fragments attached to them so we need an array to hold the import statements if fragment_path: + # download and cleanup the fragment from registry if it's not local already + downloaded_fragment = False + if not os.path.exists(fragment_path): + fragment_path = oras_proxy.pull(fragment_path) + downloaded_fragment = True import_statements = [cose_client.generate_import_from_path(fragment_path, minimum_svn=minimum_svn)] + if downloaded_fragment: + os_util.clean_up_temp_folder(fragment_path) + elif image_name: import_statements = oras_proxy.generate_imports_from_image_name(image_name, minimum_svn=minimum_svn) @@ -260,14 +261,15 @@ def acifragmentgen_confcom( if os.path.isfile(fragments_json): fragments_file_contents = os_util.load_json_from_file(fragments_json) if isinstance(fragments_file_contents, list): - logger.error( + eprint( "%s %s %s %s", "Unsupported JSON file format. ", "Please make sure the outermost structure is not an array. ", "An empty import file should look like: ", - REGO_IMPORT_FILE_STRUCTURE + REGO_IMPORT_FILE_STRUCTURE, + exit_code=1 ) - sys.exit(1) + fragments_list = fragments_file_contents.get(POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS, []) # convert to list if it's just a dict @@ -308,14 +310,11 @@ def acifragmentgen_confcom( individual_image=bool(image_name), tar_mapping=tar_mapping ) - # if no feed is provided, use the first image's feed - # to assume it's an image-attached fragment - if not image_target: - policy_images = policy.get_images() - if not policy_images: - logger.error("No images found in the policy or all images are covered by fragments") - sys.exit(1) - image_target = policy_images[0].containerImage + # make sure we have images to generate a fragment + policy_images = policy.get_images() + if not policy_images: + eprint("No images found in the policy or all images are covered by fragments") + if not feed: # strip the tag or hash off the image name so there are stable feed names feed = get_image_name(image_target) @@ -336,8 +335,10 @@ def acifragmentgen_confcom( out_path = filename + ".cose" cose_proxy.cose_sign(filename, key, chain, feed, iss, algo, out_path) - if upload_fragment: + if upload_fragment and image_target: oras_proxy.attach_fragment_to_image(image_target, out_path) + elif upload_fragment: + oras_proxy.push_fragment_to_registry(feed, out_path) def katapolicygen_confcom( diff --git a/src/confcom/azext_confcom/data/internal_config.json b/src/confcom/azext_confcom/data/internal_config.json index 84bb5c2ddc4..090e90b8b43 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.6", + "version": "1.2.7", "hcsshim_config": { "maxVersion": "1.0.0", "minVersion": "0.0.1" diff --git a/src/confcom/azext_confcom/docs/README.md b/src/confcom/azext_confcom/docs/README.md new file mode 100644 index 00000000000..af5c05ddad1 --- /dev/null +++ b/src/confcom/azext_confcom/docs/README.md @@ -0,0 +1,103 @@ +# Confcom Extension Documentation + +Welcome to the Azure CLI Confcom (Confidential Computing) extension documentation. +This extension provides tools for generating security policies and fragments for confidential computing workloads in Azure Container Instances (ACI) and Kata Containers. + +## Getting Started + +If you're new to the confcom extension, we recommend starting with: + +1. **Installation**: Follow the main README.md in the parent directory for installation instructions +2. **Core Concepts**: Read `common.md` and `policy_enforcement_points.md` to understand the fundamentals +3. **Choose Your Use Case**: + - For Azure Container Instances: Start with `acipolicygen.md` + - For policy fragments: Begin with `acifragmentgen.md` + - For Kata Containers: Review `katapolicygen.md` +4. **Configuration**: Refer to `v1_json_file_format.md` for input file structures +5. **Testing**: Use `testing.md` to validate your setup and policies + +## File Overview + +This directory contains comprehensive documentation for all aspects of the confcom extension. +Below is a guide to each file and what you'll find in it: + +### Core Concepts Documentation + +#### `common.md` + +Shared Concepts and Utilities + +- Common functionality shared across all confcom commands +- Explains core concepts used throughout the extension +- Covers shared parameters, configuration options, and data structures +- Provides foundational knowledge needed for all other documentation + +#### `policy_enforcement_points.md` + +Policy Enforcement Architecture + +- Deep dive into how security policies are enforced in confidential container environments +- Explains the relationship between policy fragments and enforcement points +- Covers the architecture of policy validation and execution +- Details integration points with Azure confidential computing services + +#### `v1_json_file_format.md` + +JSON Configuration File Format + +- Specification for the v1 JSON file format used by confcom commands +- Defines schema and structure for input configuration files +- Provides examples and validation rules for JSON inputs +- Covers migration from older formats and version compatibility + +### Command Documentation + +#### `acipolicygen.md` + +Azure Container Instances Policy Generator + +- Detailed guide for the `az confcom acipolicygen` command +- Explains how to generate confidential computing security policies for ACI workloads +- Covers policy generation from ARM templates, images, and JSON configurations +- Includes examples for CCE policy creation +- Shows how to inject policies into ARM templates and work with parameters +- Contains troubleshooting and best practices for ACI security policies + +#### `acifragmentgen.md` + +Azure Container Instances Fragment Generator + +- Documentation for the `az confcom acifragmentgen` command +- Explains policy fragments and their role in confidential computing security +- Covers two types of fragments: image-attached and standalone fragments +- Provides examples for creating and managing security fragments +- Links to certificate and signing documentation for fragment authentication +- Details ORAS registry integration for fragment storage + +#### `katapolicygen.md` + +Kata Containers Policy Generator + +- Guide for the `az confcom katapolicygen` command +- Focuses on generating security policies specifically for Kata Containers +- Covers Kata-specific security requirements and policy structures +- Includes examples and usage patterns for Kata workloads +- Explains integration with Kubernetes and container runtime security + +### Development and Testing + +#### `testing.md` + +Testing Guidelines and Procedures + +- Comprehensive testing documentation for the confcom extension +- Covers unit tests, integration tests, and end-to-end testing scenarios +- Explains test setup, execution, and validation procedures +- Includes guidelines for testing security policies and fragments +- Provides troubleshooting for common testing issues + +## Support and Contribution + +For issues, feature requests, or contributions, please refer to the main Azure CLI extensions repository. Each command documentation includes troubleshooting sections and common error resolutions. + +The confcom extension is actively developed and supports the latest Azure confidential computing features. Check the main README.md for current limitations and supported platforms. \ No newline at end of file diff --git a/src/confcom/azext_confcom/docs/acifragmentgen.md b/src/confcom/azext_confcom/docs/acifragmentgen.md new file mode 100644 index 00000000000..b96edf56358 --- /dev/null +++ b/src/confcom/azext_confcom/docs/acifragmentgen.md @@ -0,0 +1,170 @@ +# acifragmentgen + +- [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 acifragmentgen' Extension Examples + +Run `az confcom acifragmentgen --help` to see a list of supported arguments along with explanations. +The following commands demonstrate the usage of different arguments to generate confidential computing security fragments. + +For information on what a policy fragment is, see [policy fragments](./policy_enforcement_points.md). +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 + +#### Example 1 + +The following command creates a security fragment and prints it to stdout as well as saving it to a file `contoso.rego`: + +```bash +az confcom acifragmentgen --input ./fragment_config.json --svn 1 --namespace contoso +``` + +The config file is a JSON file that contains the following information: + +```json +{ + "containers": [ + { + "name": "my-image", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "environmentVariables": [ + { + "name": "PATH", + "value": "/customized/path/value" + }, + { + "name": "TEST_REGEXP_ENV", + "value": "test_regexp_env(.*)", + "regex": true + } + ], + "command": [ + "python3", + "main.py" + ] + } + } + ] +} +``` + +The `--svn` argument is used to specify the security version number of the fragment and should be an integer. +The `--namespace` argument is used to specify the namespace of the fragment and cannot conflict with some built-in names. +If a conflicting name occurs, there will be an error message. +This list of reserved names can be found [here under 'reserved_fragment_namespaces'](./data/internal_config.json). +The format of the config file generally follows that of the [ACI resource in an ARM template](https://learn.microsoft.com/en-us/azure/templates/microsoft.containerinstance/containergroups?pivots=deployment-language-arm-template). + +#### Example 2 + +This command creates a signed security fragment and attaches it to a container image in an ORAS-compliant registry: + +```bash +az confcom acifragmentgen --chain ./samples/certs/intermediateCA/certs/www.contoso.com.chain.cert.pem --key ./samples/certs/intermediateCA/private/ec_p384_private.pem --svn 1 --namespace contoso --input ./samples/config.json --upload-fragment +``` + +#### Example 3 + +This command creates a file to be used by `acipolicygen` that says which fragments should be included in the policy. +Note that the policy must be [COSE](https://www.iana.org/assignments/cose/cose.xhtml) signed: + +```bash +az confcom acifragmentgen --generate-import -p ./contoso.rego.cose --minimum-svn 1 --fragments-json fragments.json +``` + +This outputs a file `fragments.json` that contains the following information: + +```json +{ + "fragments": [ + { + "feed": "contoso.azurecr.io/example", + "includes": [ + "containers", + "fragments" + ], + "issuer": + "did:x509:0:sha256:mLzv0uyBNQvC6hi4y9qy8hr6NSZuYFv6gfCwAEWBNqc::subject:CN:Contoso", + "minimum_svn": "1" + } + ] +} +``` + +This file is then used by `acipolicygen` to generate a policy that includes custom fragments. + +#### Example 4 + +The command creates a signed policy fragment and attaches it to a specified image in an ORAS-compliant registry: + +```bash +az confcom acifragmentgen --chain ./samples/certs/intermediateCA/certs/www.contoso.com.chain.cert.pem --key ./samples/certs/intermediateCA/private/ec_p384_private.pem --svn 1 --namespace contoso --input ./samples/.json --upload-fragment --image-target contoso.azurecr.io/:latest --feed contoso.azurecr.io/ +``` + +This could be useful in scenarios where an image-attached fragment is required but the fragment's feed is different from the image's location. + +#### Example 5 + +This format can also be used to generate fragments used for VN2. VN2 is described in more depth [in this file](./acipolicygen.md). +Adding the `scenario` key with the value `vn2` tells confcom which default values need to be added. +Save this file as `fragment_config.json`: + +```json +{ + "version": "1.0", + "scenario": "vn2", + "containers": [ + { + "name": "my-image", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9" + } + } + ] +} +``` + +Using the same command, the default mounts and environment variables used by VN2 will be added to the policy fragment. + +```bash +az confcom acifragmentgen --input ./fragment_config.json --svn 1 --namespace contoso +``` + +#### Example 6 + +Create an import statement from a signed fragment in a remote repo: + +```bash +az confcom acifragmentgen --generate-import --fragment-path contoso.azurecr.io/:v1 --minimum-svn 1 +``` + +This is assuming there is a standalone fragment present at the specified location of `contoso.azurecr.io/:v1`. Fragment imports can also be created using local paths to signed fragment files such as: + +```bash +az confcom acifragmentgen --generate-import --fragment-path ./contoso.rego.cose --minimum-svn 1 +``` + +#### Example 7 + +Create an import statement from a signed image-attached fragment in a remote repo: + +```bash +az confcom acifragmentgen --generate-import --image contoso.azurecr.io/: --minimum-svn 1 +``` + +Note that since the fragment is image-attached, the `--image` argument is used instead of `--fragment-path` and the image cannot be a local image in the docker daemon. diff --git a/src/confcom/azext_confcom/docs/acipolicygen.md b/src/confcom/azext_confcom/docs/acipolicygen.md new file mode 100644 index 00000000000..861ab769784 --- /dev/null +++ b/src/confcom/azext_confcom/docs/acipolicygen.md @@ -0,0 +1,392 @@ +# acipolicygen + +- [Microsoft Azure CLI 'confcom acipolicygen' Extension Examples](#microsoft-azure-cli-confcom-acipolicygen-extension-examples) +- [AKS Virtual Node (VN2)](#aks-virtual-node) + +## Microsoft Azure CLI 'confcom acipolicygen' Extension Examples + +Run `az confcom acipolicygen --help` to see a list of supported arguments along with explanations. +The following commands demonstrate the usage of different arguments to generate confidential computing security policies. + +### Prerequisites + +View the [common documentation](./common.md) for information on how to install the `confcom` extension. + +The `acipolicygen` command generates confidential computing security policies using an image, an input JSON file, or an ARM template. +You can control the format of the generated policies using arguments. +Note: It is recommended to use images with specific tags instead of the `latest` tag, as the `latest` tag can change at any time and images with different configurations may also have the latest tag. + +### Examples + +#### Example 1 + +The following command creates a CCE policy and outputs it to the command line: + +```bash +az confcom acipolicygen -a .\template.json --print-policy +``` + +This command combines the information of images from the ARM template with other information such as mount, environment variables and commands from the ARM template to create a CCE policy. +The `--print-policy` argument is included to display the policy on the command line rather than injecting it into the input ARM template. + +#### Example 2 + +This command injects a CCE policy into [ARM-template](arm.template.md) based on input from [parameters-file](template.parameters.md) so that there is no need to change the ARM template to pass variables into the CCE policy: + +```bash +az confcom acipolicygen -a .\arm-template.json -p .\template.parameters.json +``` + +This is mainly for decoupling purposes so that an ARM template can remain the same and evolving variables can go into a different file. +When a security policy gets injected into the ARM Template, the corresponding sha256 hash of the decoded security policy gets printed to the command line. +This sha256 hash can be used for verifying the hostdata field of the SEV-SNP Attestation Report and/or used for key release policies using MAA (Microsoft Azure Attestation) or mHSM (managed Hardware Security Module) + +#### Example 3 + +This command takes the input of an ARM template to create a human-readable CCE policy in pretty print JSON format and output the result to the console. +NOTE: Generating JSON policy is for use by the customer only, and is not used by ACI in most cases. +The default REGO format security policy is required. + +```bash +az confcom acipolicygen -a ".\arm_template" --outraw-pretty-print +``` + +The default output of `acipolicygen` command is base64 encoded REGO format. +This example uses `--outraw-pretty-print` to indicate decoding policy in clear text with pretty print format and to print result to console. + +#### Example 4 + +The following command takes the input of an ARM template to create a human-readable CCE policy in clear text and print to console: + +```bash +az confcom acipolicygen -a ".\arm-template.json" --outraw +``` + +Use `--outraw` argument to output policy in clear text compact REGO format. + +#### Example 5 + +Input an ARM template to create a human-readable CCE policy in pretty print REGO format and save the result to a file named ".\output-file.rego": + +```bash +az confcom acipolicygen -a ".\arm-template" --outraw-pretty-print --save-to-file ".\output-file.rego" +``` + +#### Example 6 + +Validate the policy present in the ARM template under "ccepolicy" and the containers within the ARM template are compatible. +If they are incompatible, a list of reasons is given and the exit status code will be 2: + +```bash +az confcom acipolicygen -a ".\arm-template.json" --diff +``` + +#### Example 7 + +Decode the existing CCE policy in ARM template and print to console in clear text. + +```bash +az confcom acipolicygen -a ".\arm-template.json" --print-existing-policy +``` + +#### Example 8 + +Generate a CCE policy using `--disable-stdio` argument. +`--disable-stdio` argument disables container standard I/O access by setting `allow_stdio_access` to false. + +```bash +az confcom acipolicygen -a ".\arm-template.json" --disable-stdio +``` + +#### Example 9 + +Inject a CCE policy into ARM template. +This command adds the `--debug-mode` argument to enable executing /bin/sh and /bin/bash in the container group: + +```bash +az confcom acipolicygen -a .\sample-arm-input.json --debug-mode +``` + +In the above example, The `--debug-mode` modifies the following to allow users to shell into the container via portal or the command line: + +1. Adds the following to container rule so that users can access bash process. + +```json +"exec_processes": [ + { + "command": ["/bin/sh"], + "signals": [] + }, + { + "command": ["/bin/bash"], + "signals": [] + } +] +``` + +2. Changes the values of these three rules to true on the policy. +This is also for the purpose of allowing users to access logging, container properties and dump stack, all of which are part of loggings as well. +See [A Sample Policy that Uses Framework](./policy_enforcement_points.md) for details for the following rules: + + - allow_properties_access + - allow_dump_stacks + - allow_runtime_logging + +#### Example 10 + +The confidential computing extension CLI is designed in such a way that generating a policy does not necessarily have to depend on network calls as long as users have the layers of the images they want to generate policies for saved in a tar file locally. +See the following example: + +```bash +docker save ImageTag -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 +``` + +Some users have unique scenarios such as cleanroom requirement. +In this case, users can still generate security policies without relying on network calls. +Users just need to make a tar file by using the `docker save` command above, include the `--tar` argument when making the `acipolicygen` command and make sure the input JSON file contains the same image tag. + +When generating security policy without using `--tar` argument, the confcom extension CLI tool attempts 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 + +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 +docker save image:tag2 -o file2.tar +docker save image:tag3 -o file3.tar +``` + +Create the following file (as an example named "tar_mappings.json") on the local filesystem: + +```json +{ + "image:tag1": "./file1.tar", + "image:tag2": "./file2.tar", + "image:tag3": "./file3.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 .\tar_mappings.json +``` + +#### 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: + +```json +{ + "parameters": { + "placeholderValue": { + "type": "string", + "metadata": { + "description": "This value will not be placed in the template.json" + } + } + }, +} +``` + +Note that this parameter declaration does not have a "defaultValue". +Once these parameters are defined, they may be used later in the ARM Template such as: + +```json +{ + "environmentVariables": [ + { + "name": "PATH", + "value": "/customized/path/value" + }, + { + "name": "MY_SECRET", + "value": "[parameters('placeholderValue')]" + } + ] +} +``` + +The policy can then be generated with: + +```bash +az confcom acipolicygen -a template.json +``` + +Because the ARM Template does not have a value defined for the "placeholderValue", the regular expression ".*" is used in the Rego policy. +This allows for any value to be used. +If the value is contained in a parameters file, that can be used when deploying such as: + +```bash +az deployment group create --template-file "template.json" --parameters "parameters.json" +``` + +#### Example 13 + +Another way to add additional flexibility to a security policy is by using a "pure json" approach to the config file. +This gives the flexibility of using regular expressions for environment variables and including fragments without the need for the `--fragments-json` flag. +It uses the same format as `acifragmentgen` such that if there needs to be different deployments with similar configs, very few changes are needed. +Note that a unique name for each container is required. + +```json +{ + "fragments": [ + { + "feed": "contoso.azurecr.io/example", + "includes": [ + "containers", + "fragments" + ], + "issuer": "did:x509:0:sha256:mLzv0uyBNQvC6hi4y9qy8hr6NSZuYFv6gfCwAEWBNqc::subject:CN:Contoso", + "minimum_svn": "1" + } + ], + "containers": [ + { + "name": "my-image", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "environmentVariables": [ + { + "name": "PATH", + "value": "/customized/path/value" + }, + { + "name": "TEST_REGEXP_ENV", + "value": "test_regexp_env(.*)", + "regex": true + } + ], + "execProcesses": [ + { + "command": [ + "ls" + ] + } + ], + "volumeMounts": [ + { + "name": "mymount", + "mountPath": "/mount/mymount", + "mountType": "emptyDir", + "readOnly": false + } + ], + "command": [ + "python3", + "main.py" + ] + } + } + ] +} +``` + +```bash +az confcom acipolicygen -i config.json +``` + +## AKS Virtual Node + +Azure Kubernetes Service (AKS) allows pods to be scheduled on Azure Container Instances (ACI) using the [AKS Virtual Node](https://learn.microsoft.com/en-us/azure/aks/virtual-nodes) feature. +The `confcom` tooling can generate security policies for these ACI-based pods in the same way as for standalone ACI container groups. +The key difference is that the `confcom` tooling will ingest an AKS pod specification (`pod.yaml`) instead of an ARM Template. +When the AKS pod specification is deployed, it must have an annotation `microsoft.containerinstance.virtualnode.ccepolicy` denoting its security policy. +This annotation is automatically added to the yaml file when the policy is created. + +### Examples + +#### Example 1 + +Use the following command to generate and print a security policy for an AKS pod running on ACI: + +```bash +az confcom acipolicygen --virtual-node-yaml ./pod.yaml --print-policy +``` + +Where `pod.yaml` is a Kubernetes Pod resource: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: sample-mcr-pod +spec: + containers: + - name: helloworld + image: mcr.microsoft.com/acc/samples/aci/helloworld:2.9 + ports: + - containerPort: 80 +``` + +The input can be other Kubernetes resource types like Deployments, Cronjobs, and StatefulSets. +If ConfigMaps or Secrets are used, they should be included in the same file as the Pod-creating resource. +If the ConfigMap or Secret is not included, a prompt will appear asking if the environment variable should be wildcarded. +To automate this process, the `-y` flag may be used. + +#### Example 2 + +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.9" + } + } + ] +} +``` + +And the policy is created with: + +```bash +az confcom acipolicygen -i input.json +``` + +This `scenario` field adds the necessary environment variables and mount values to containers in the config file. +Currently `vn2` and `aci` are the only supported values for `scenario`, but others may be added in the future as more products onboard to the `confcom` extension. +`aci` is the default value. + +### 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. +To generate a policy with workload identity capabilities for VN2 using the JSON format, the following label must be included: + +```json +{ + "version": "1.0", + "scenario": "vn2", + "labels": { + "azure.workload.identity/use": true + }, + "containers": [ + ... + ] +} +``` + +> [!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/docs/common.md b/src/confcom/azext_confcom/docs/common.md new file mode 100644 index 00000000000..fe76406adf5 --- /dev/null +++ b/src/confcom/azext_confcom/docs/common.md @@ -0,0 +1,58 @@ +# Common Documentation + +- [Security Policy Information Sources](#security-policy-information-sources) + +## Security Policy Information Sources + +Each container in a security policy can get its information from two different sources: + +1. The image manifest. +This can be explored using `docker image inspect` +2. The ARM Template used to generate the security policy. +This can be used for startup command, environment variables, etc. + +The `confcom` tooling uses the image manifest for default values and then adds or overwrites those values using what is found in the ARM Template. +For a full reference of the container‑group schema in ARM templates, see the [Microsoft.ContainerInstance ARM template documentation](https://learn.microsoft.com/en-us/azure/templates/microsoft.containerinstance/containergroups?pivots=deployment-language-arm-template) + +## dm-verity Layer Hashing + +To ensure the integrity of the deployed container, the `confcom` tooling uses [dm-verity hashing](https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/verity.html). +This is done by downloading the container locally with the Docker Daemon (or using a pre-downloaded tar file of the OCI image) and performing the dm-verity hashing using the [dmverity-vhd tool](https://github.com/microsoft/hcsshim/tree/main/cmd/dmverity-vhd). +These layer hashes are placed into the Rego security policy in the "layers" field of their respective container. +Note that these dm-verity layer hashes are different than the layer hashes reported by `docker image inspect`. + +### Mixed-mode Policy Generation + +An OCI image can be made available for policy generation in three ways: + +1. The image is in the local Docker Daemon and can be found either with its image and tag names or its sha256 hash. +2. The image is in an accessible remote repository. +Usually this is either Docker Hub or Azure Container Registry. +Note that if the registry is private, you must log in prior to policy generation. +3. The image is locally saved as a tar file in the form specified by `docker save`. + +Mixed-mode policy generation is available in the `confcom` tooling, meaning images within the same security policy can be in any of these three locations with no issues. + +## Installation and Usage + +Install the Azure CLI and Confidential Computing extension. + +To see the most recently released version of `confcom` extension, run: + +```bash +az extension list-available -o table | grep confcom +``` + +To add the most recent confcom extension, run: + +```bash +az extension add --name confcom +``` + +Use the `--version` argument to specify a version to add. + +Run this to update to the latest version if an older version is already installed: + +```bash +az extension update --name confcom +``` diff --git a/src/confcom/azext_confcom/docs/katapolicygen.md b/src/confcom/azext_confcom/docs/katapolicygen.md new file mode 100644 index 00000000000..582a5956508 --- /dev/null +++ b/src/confcom/azext_confcom/docs/katapolicygen.md @@ -0,0 +1,45 @@ +# katapolicygen + +- [Microsoft Azure CLI 'confcom katapolicygen' Extension Examples](#microsoft-azure-cli-confcom-katapolicygen-extension-examples) + +## Microsoft Azure CLI 'confcom katapolicygen' Extension Examples + +Run `az confcom katapolicygen --help` to see a list of supported arguments along with explanations. +The following commands demonstrate the usage of different arguments to generate confidential computing security policies. + +### Prerequisites + +View the [common documentation](./common.md) for information on how to install the `confcom` extension. + +The `katapolicygen` command generates confidential computing security policies using a kubernetes pod spec. +You can control the format of the generated policies using arguments. +Note: It is recommended to use images with specific tags instead of the `latest` tag, as the `latest` tag can change at any time and images with different configurations may also have the latest tag. + +### Examples + +#### Example 1 + +The following command creates a security policy and outputs it to the command line: + +```bash +az confcom katapolicygen -y ./pod.yaml --print-policy +``` + +This command combines the information of images from the pod spec with other information such as mount, environment variables and commands from the pod spec to create a security policy. +The `--print-policy` argument is included to display the policy on the command line in addition to injecting it into the input pod spec. + +#### Example 2 + +This command injects a security policy into the pod spec based on input from a config map so that there is no need to change the pod spec to pass variables into the security policy: + +```bash +az confcom katapolicygen -y ./pod.yaml -c ./config-map.yaml +``` + +#### Example 3 + +This command caches the layer hashes and stores them locally on your computer to make future computations faster if the same images are used: + +```bash +az confcom katapolicygen -y ./pod.yaml -u +``` diff --git a/src/confcom/azext_confcom/docs/policy_enforcement_points.md b/src/confcom/azext_confcom/docs/policy_enforcement_points.md new file mode 100644 index 00000000000..9ce26f93b4a --- /dev/null +++ b/src/confcom/azext_confcom/docs/policy_enforcement_points.md @@ -0,0 +1,394 @@ +# Enforcement Points + +- [Security Policy Rules Documentation](#security-policy-rules-documentation) + - [mount_device](#mount_device) + - [unmount_device](#unmount_device) + - [mount_overlay](#mount_overlay) + - [unmount_overlay](#unmount_overlay) + - [create_container](#create_container) + - [exec_in_container](#exec_in_container) + - [exec_external](#exec_external) + - [shutdown_container](#shutdown_container) + - [signal_container_process](#signal_container_process) + - [plan9_mount](#plan9_mount) + - [plan9_unmount](#plan9_unmount) + - [scratch_mount](#scratch_mount) + - [scratch_unmount](#scratch_unmount) + - [load_fragment](#load_fragment) + - [policy fragments](#policy-fragments) + - [reason](#reason) + - [A Sample Policy that Uses Framework](#a-sample-policy-that-uses-framework) + - [allow_properties_access](#a-sample-policy-that-uses-framework) + - [allow_dump_stack](#a-sample-policy-that-uses-framework) + - [allow_runtime_logging](#a-sample-policy-that-uses-framework) + - [allow_environment_variable_dropping](#allow_environment_variable_dropping) + - [allow_unencrypted_scratch](#allow_unencrypted_scratch) + - [allow_capabilities_dropping](#allow_capabilities_dropping) + +## Security Policy Rules Documentation + +This document describes every enforcement point that a security policy must implement and the input each rule receives. +All rules live in the policy's namespace and must return an object with at least the member allowed (boolean) that states whether the requested action is permitted. + +Below is an example rego policy: + +```rego +package mypolicy + +import future.keywords.every +import future.keywords.in + +api_version := "0.10.0" +framework_version := "0.1.0" + +fragments := [...] + +containers := [...] + +allow_properties_access := true +allow_dump_stacks := false +allow_runtime_logging := false +allow_environment_variable_dropping := true +allow_unencrypted_scratch := false +allow_capabilities_dropping := true + + + +mount_device := data.framework.mount_device +unmount_device := data.framework.unmount_device +mount_overlay := data.framework.mount_overlay +unmount_overlay := data.framework.unmount_overlay +create_container := data.framework.create_container +exec_in_container := data.framework.exec_in_container +exec_external := data.framework.exec_external +shutdown_container := data.framework.shutdown_container +signal_container_process := data.framework.signal_container_process +plan9_mount := data.framework.plan9_mount +plan9_unmount := data.framework.plan9_unmount +get_properties := data.framework.get_properties +dump_stacks := data.framework.dump_stacks +runtime_logging := data.framework.runtime_logging +load_fragment := data.framework.load_fragment +scratch_mount := data.framework.scratch_mount +scratch_unmount := data.framework.scratch_unmount + +reason := {"errors": data.framework.errors} +``` + +We document each rule as follows: + +## mount_device + +Receives an input object with the following members: + +```json +{ + "name": "mount_device", + "target": "", + "deviceHash": "" +} +``` + +## unmount_device + +Receives an input object with the following members: + +```json +{ + "name": "unmount_device", + "unmountTarget": "" +} +``` + +## mount_overlay + +Describe the layers to mount: + +```json +{ + "name": "mount_overlay", + "containerID": "", + "layerPaths": [ + "", + "", + "", + /*...*/ + ], + "target": "" +} +``` + +## unmount_overlay + +Receives an input object with the following members: + +```json +{ + "name": "unmount_overlay", + "unmountTarget": "" +} +``` + +## create_container + +Indicates whether the UVM (Utility-VM) is allowed to create a specific container with the exact parameters provided to the method. +Provided in the following input object, the framework rule checks the exact parameters such as (command, environment variables, mounts etc.) + +```json +{ + "name": "create_container", + "containerID": "", + "argList": [ + "", + "", + "", + /*...*/ + ], + "envList": [ + "=", + /*...*/ + ], + "workingDir": "", + "sandboxDir": "", + "hugePagesDir": "", + "mounts": [ + { + "destination": "", + "options": [ + "", + "", + /*...*/ + ], + "source": "", + "type": ""}, + ], + "privileged": "" +} +``` + +## exec_in_container + +Determines if a process should be executed in a container based on its command, arguments, environment variables, and working directory. +Receives an input object with the following elements: + +```json +{ + "containerID": "", + "argList": [ + "", + "", + "", + /*...*/ + ], + "envList": [ + "=", + /*...*/ + ], + "workingDir": "" +} +``` + +## exec_external + +Determines whether a process may run directly inside the UVM, outside of the container sandbox. +Receives an input object with the following elements: + +```json +{ + "name": "exec_external", + "argList": [ + "", + "", + "", + /*...*/ + ], + "envList": [ + "=", + /*...*/ + ], + "workingDir": "" +} +``` + +## shutdown_container + +Receives an input object with the following elements: + +```json +{ + "name": "shutdown_container", + "containerID": "" +} +``` + +## signal_container_process + +Describe the signal sent to the container. +Receives an input object with the following elements: + +```json +{ + "name": "signal_container_process", + "containerID": "", + "signal": "", + "isInitProcess": "", + "argList": [ + "", + "", + "", + /*...*/ + ] +} +``` + +## plan9_mount + +Controls which host directories may be mounted via the 9P (Plan 9) protocol into the UVM, so that the UVM can bind‑mount those directories into containers later. +Azure Confidential Computing evaluates this host → UVM → container path because an attacker could overwrite an attested directory on the UVM and have malicious data flow into containers. +The target field therefore designates exactly where the mount is created, allowing the policy to block dangerous destinations. +It receives an input with the following elements: + +```json +{ + "name": "plan9_mount", + "target": "" +} +``` + +## plan9_unmount + +Receives an input with the following elements: + +```json +{ + "name": "plan9_unmount", + "unmountTarget": "" +} +``` + +## scratch_mount + +Scratch is writable storage from the UVM to the container. +It receives an input with the following elements: + +```json +{ + "name": "scratch_mount", + "target": "", + "encrypted": "true|false" +} +``` + +## scratch_unmount + +Receives an input with the following elements: + +```json +{ + "name": "scratch_unmount", + "unmountTarget": "", +} +``` + +## load_fragment + +This rule is used to determine whether a policy fragment can be loaded. +See [policy fragments](#policy-fragments) for detailed explanation. + +## policy fragments + +Why do we need policy fragments? + +Confidential Containers provide the core primitives for allowing customers to build container-based application solutions that leave Microsoft and Microsoft operators outside of the TCB (Trusted Computing Base). + +A policy fragment is a small, customer‑signed, COSE‑sealed Rego module that extends the baseline policy. The typical use‑case is to allow updated ACI sidecar images to run without widening the overall trust boundary or adding Microsoft operators to the TCB. In practice the fragment says: + +> “Alongside the digests pinned in my baseline policy, I also trust images from feed X that is in the allowed list of signers.” + +Because the fragment is signed by the customer and verified inside the guest, you stay in full control of what expands the trusted set—Microsoft does not gain new power over your TCB. + +In order to achieve this, our environment has to implement enforcement policies that not only dictate which containers are allowed to run, but also the explicit versions of each container that are allowed to run. +The implication of this is that in the case of Confidential ACI, if the customer is allowing ACI provided sidecars into their TCB, the customer environment won't be able to be start if ACI updates any of their sidecars for regular maintenance. + +Given that some customers will want to allow ACI sidecars into their trusted environment, we need to provide a way for customers to indicate a level of trust in ACI so that sidecars that ACI has indicated are theirs and that the customer has agreed to accept can be run. + +In order to achieve this, We will allow additional constraints to be provided to a container environment. +And we call these additionally defined constraints "policy fragments". +Policy fragments can serve a number of use-cases. +For now, we will focus on the ACI sidecar use case. +See the following example and how it defines a policy fragment. +The following policy fragment states that my confidential computing environment should trust containers published by the DID `did:web:accdemo.github.io` on the feed named `accdemo/utilities`. + +```rego +fragments := [ + { + "feed": "accdemo/utilities", + "iss": "did:web:accdemo.github.io", + "includes": [<"containers"|"fragments"|"external_processes"|"namespace">] + } +] + +default load_fragment := [] +load_fragment := includes { + some fragment in fragments + input.iss == fragment.iss + input.feed == fragment.feed + includes := fragment.includes +} +``` + +Every time a policy fragment is presented to the enclosing system (e.g. GCS), the enclosing system is provided with a COSE_Sign1 signed envelope. +The header in the envelope contains the feed and the issuer and these information are included in the `input` context. +The logic of load_fragment rule selects a policy fragment from the list of policy fragments which matches the issuer and feed. +The enclosing system loads the policy fragment and queries it for the `includes` e.g. `container` and inserts them into the data context for later use. + +## reason + +A policy can optionally define this rule, which will be called when a policy issues a denial. +This is used to populate an informative error message. + +## A Sample Policy that Uses Framework + +A more detailed explanation is provided for the following rules that seem to appear more than once on the CCE policy: +`allow_properties_access`, `get_properties`, `allow_dump_stacks`, `dump_stacks`, `allow_runtime_logging` and `runtime_logging` + +Rego framework supports policy authors by both describing the form that user policies should take, and consequently the form that Microsoft-provided Rego modules will follow. +It also provides some pre-built policy components that can make policy authoring easier. +Microsoft provides a [Rego Framework](https://github.com/microsoft/hcsshim/blob/main/pkg/securitypolicy/framework.rego) to make writing policies easier. +It contains a collection of helper functions, which in turn provide default implementations of the required rules. +These functions operate on Rego data with expected formats. +We include a [sample policy](sample_policy.md) which uses this framework. + +The difference between `allow_properties_access` vs `get_properties`: + +There is an API that defines a rule called `get_properties`. +A custom user policy can implement this however it wants. +However, a policy that uses the framework indicates their desired behavior to the framework with a flag called `allow_get_properties`. If you look at the framework implementation for get_properties you will see that it returns data.policy.allow_get_properties. +The same logic applies to both dump_stack and runtime_logging. + +`allow_properties_access` VS `get_properties` +When set to true, this indicates that `get_properties` should be allowed. +It indicates whether the host can fetch properties from a container. + +`allow_dump_stacks` VS `dump_stacks` +When set to true, this indicates that `dump_stacks` should be allowed. + +`allow_runtime_logging` VS `runtime_logging` +When `allow_runtime_logging` is set to true, this indicates that `runtime_logging` should be allowed. +Runtime logging is logging done by the UVM, i.e. outside of containers and their processes. + +## allow_environment_variable_dropping + +The allow_environment_variable_dropping flag allows the framework, if set, to try to drop environment variables if there are no matching containers/processes. +This is an important aspect of serviceability, as it allows customer policies to be robust to the addition of extraneous environment variables which are unused by their containers. +Note throughout this that the existing logic of required environment variables holds. +The logic of dropping env vars is a bit complex but in general the framework looks at the intersection of the set of provided variables and the environment variable rules of each container/process and finds the largest set, which happens to be the one that requires dropping the least number of env vars. +It then tests to see if that set satisfies any containers. + +## allow_unencrypted_scratch + +This rule determines whether unencrypted writable storage from the UVM to the container is allowed. + +## allow_capabilities_dropping + +Whether to allow capabilities to be dropped in the same manner as allow_environment_variable_dropping. \ No newline at end of file diff --git a/src/confcom/azext_confcom/tests/latest/README.md b/src/confcom/azext_confcom/docs/testing.md similarity index 95% rename from src/confcom/azext_confcom/tests/latest/README.md rename to src/confcom/azext_confcom/docs/testing.md index 469edd7f905..95ae66638b1 100644 --- a/src/confcom/azext_confcom/tests/latest/README.md +++ b/src/confcom/azext_confcom/docs/testing.md @@ -160,6 +160,7 @@ test_fragment_user_container_customized_mounts | mcr.microsoft.com/azurelinux/di test_fragment_user_container_mount_injected_dns | mcr.microsoft.com/azurelinux/distroless/base:3.0 | See if the resolvconf mount works properly test_fragment_omit_id | mcr.microsoft.com/aci/msi-atlas-adapter:master_20201203.1 | Check that the id field is omitted from the policy test_fragment_injected_sidecar_container_msi | mcr.microsoft.com/aci/msi-atlas-adapter:master_20201203.1 | Make sure User mounts and env vars aren't added to sidecar containers, using JSON output format +test_tar_file_fragment | mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64 | Make sure fragment generation doesn't fail for image tarball test_debug_processes | mcr.microsoft.com/azurelinux/distroless/base:3.0 | Enable exec_processes via debug_mode 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 @@ -167,6 +168,11 @@ test_fragment_incorrect_sidecar | mcr.microsoft.com/aci/msi-atlas-adapter:master test_signing | mcr.microsoft.com/acc/samples/aci/helloworld:2.9 | Sign a fragment with a key and chain file test_generate_import | mcr.microsoft.com/acc/samples/aci/helloworld:2.9 | Generate an import statement for the signed fragment file test_local_fragment_references | mcr.microsoft.com/acc/samples/aci/helloworld:2.9 | Make sure the fragment references are correct when the fragment is local +test_registry_is_running | N/A | See if the local registry is running +test_generate_import_from_remote | mcr.microsoft.com/acc/samples/aci/helloworld:2.9 | Test generating an import statement from the feed of a signed standalone fragment +test_remote_fragment_references | mcr.microsoft.com/acc/samples/aci/helloworld:2.9 & mcr.microsoft.com/azurelinux/busybox:1.36 | Test standard standalone fragment usage +test_incorrect_minimum_svn | mcr.microsoft.com/acc/samples/aci/helloworld:2.9 | Make sure that fragments with too low of an svn are not included when using fragments +test_image_attached_fragment_coverage | mcr.microsoft.com/acc/samples/aci/helloworld:2.9 | Test nested standalone fragment generation test_invalid_input | mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 | Fail out under various invalid input circumstances ## Policy Conversion File [test file](test_confcom_policy_conversion.py) diff --git a/src/confcom/azext_confcom/docs/v1_json_file_format.md b/src/confcom/azext_confcom/docs/v1_json_file_format.md new file mode 100644 index 00000000000..8aefc73d3a2 --- /dev/null +++ b/src/confcom/azext_confcom/docs/v1_json_file_format.md @@ -0,0 +1,258 @@ +# v1 `--input`, `-i` json file format + +This document provides a comprehensive reference for the JSON configuration format used by the `confcom` extension's `--input`, `-i` flag. +This schema allows you to define container properties and fragment specifications to be used with `acipolicygen` and `acifragmentgen` including usage with `VN2`. + +## Schema Overview + +The configuration file uses a structured JSON format with the following main sections: + +| Field | Type | Required | Description | +|--------------|---------|-----------------------------------------------|-----------------------------------------------------------------------------------------------| +| `version` | string | Yes | Policy framework version identifier (currently "1.0") | +| `scenario` | string | No | Adds appropriate mounts and environment variables for different deployment scenarios. If deploying to `vn2`, this field is required | +| `fragments` | array | Yes (either `containers` or `fragments` must be defined) | Specifies policy fragments to include | +| `containers` | array | Yes (either `containers` or `fragments` must be defined) | Container configurations to deploy | + +## Detailed Field Reference + +### Top-Level Fields + +#### `version` + +- **Type**: String +- **Required**: Yes +- **Description**: Version identifier for the output policy format +- **Allowed Values**: "1.0" +- **Example**: `"version": "1.0"` + +#### `scenario` + +- **Type**: String +- **Required**: No +- **Description**: Adds appropriate mounts and environment variables for different deployment scenarios. +If deploying to `vn2`, this field is required. +The default value is `aci` +- **Allowed Values**: "vn2", "aci" +- **Example**: `"scenario": "vn2"` + +#### `fragments` + +- **Type**: Array of objects +- **Required**: Yes (either `containers` or `fragments` must be defined) +- **Description**: Policy fragments that should be included and imported when enforcing the security policy + +These objects define all the information necessary for importing and using a fragment. + +Each fragment object must include the following fields: + +| Field | Type | Required | Description | +|---------------|--------|----------|----------------------------------------------------------------------| +| `issuer` | string | Yes | DID identifier of the fragment signer | +| `feed` | string | Yes | Name for the fragment. Can be its address in a container registry (for standalone fragments) or an arbitrary name (for image-attached fragments) | +| `minimum_svn` | string | Yes | Minimum Security Version Number required | +| `includes` | array | Yes | Variables included in the fragment (e.g., "containers", "fragments") | + +**Example**: + +```json +{ + "issuer": "did:x509:0:sha256:I__iuL25oXEVFdTP_aBLx_eT1RPHbCQ_ECBQfYZpt9s::eku:1.3.6.1.4.1.311.76.59.1.3", + "feed": "contoso.azurecr.io/infra", + "minimum_svn": "1", + "includes": ["containers"] +} +``` + +#### `containers` + +- **Type**: Array of objects +- **Required**: Yes (either `containers` or `fragments` must be defined) +- **Description**: Container specifications for deployment + +Container Object Fields + +| Field | Type | Required | Description | +|------------|--------|----------|-------------------------------------| +| name | string | Yes | Unique identifier for the container | +| properties | object | Yes | Container-specific configuration | + +Container `properties` Fields + +| Field | Type | Required | Description | +|----------------------|--------|----------|--------------------------------------------------------------| +| image | string | Yes | Container image URI | +| execProcesses | array | No | Commands executed within the container (usually from probes) | +| command | array | No | Container startup command | +| volumeMounts | array | No | Volumes mounted into the container | +| environmentVariables | array | No | Environment variables set within the container | +| securityContext | object | No | Defines privileges associated with the container | + +**Example**: + +```json +{ + "containers": [ + { + "name": "my-container", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "execProcesses": [ + { + "command": ["echo", "Hello World"] + } + ], + "command": ["python3"], + "volumeMounts": [ + { + "name": "azurefile", + "mountPath": "/aci/logs", + "mountType": "azureFile", + "readOnly": true + } + ], + "environmentVariables": [ + { + "name": "PATH", + "value": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + }, + { + "name": "NEW_VAR", + "value": "value.*", + "regex": true + } + ], + "securityContext": { + "privileged": false, + "runAsUser": 1001, + "runAsGroup": 3001, + "runAsNonRoot": true, + "readOnlyRootFilesystem": true, + "capabilities": { + "add": ["NET_ADMIN"], + "drop": ["ALL"] + } + } + } + } + ] +} +``` + +`execProcesses` Object Fields + +| Field | Type | Required | Description | +|---------|-------|----------|--------------------------------| +| command | array | Yes | Command and arguments to execute| + +**Example**: + +```json +{ + "command": ["echo", "Hello World"] +} +``` + +`command` Field + +- **Type**: array of strings +- **Required**: No +- **Description**: Command executed at container startup. + +**Example**: + +```json +"command": ["python3"] +``` + +`volumeMounts` Object Fields + +| Field | Type | Required | Description | +|------------|---------|----------|---------------------------------------------------------| +| name | string | Yes | Name of the volume | +| mountPath | string | Yes | Path inside the container where volume is mounted | +| mountType | string | Yes | Type of volume (`azureFile`, `secret`, `configMap`, `emptyDir`) | +| readOnly | boolean | No | Mount volume as read-only (default: false) | + +**Example**: + +```json +{ + "name": "azurefile", + "mountPath": "/aci/logs", + "mountType": "azureFile", + "readOnly": true +} +``` + +`environmentVariables` Object Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| name | string | Yes | Environment variable name | +| value | string | Yes | Environment variable value | +| regex | boolean | No | Indicates if the value is a regex pattern (default: false) | + +**Example**: + +```json +[ + { + "name": "PATH", + "value": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + }, + { + "name": "NEW_VAR", + "value": "value.*", + "regex": true + } +] +``` + +`securityContext` Object Fields + +The `securityContext` field defines security-related settings for a container. These settings control privileges and access controls for the container process. These options are not used in most situations + +| Field | Type | Required | Description | +|-------------------|---------|----------|---------------------------------------------------------------------------------------------| +| `privileged` | boolean | No | If true, the container runs in privileged mode (not recommended for production workloads). | +| `runAsUser` | int | No | The UID to run the container process as. | +| `runAsGroup` | int | No | The GID to run the container process as. | +| `runAsNonRoot` | boolean | No | Requires the container to run as a non-root user. | +| `readOnlyRootFilesystem` | boolean | No | If true, mounts the container's root filesystem as read-only. | +| `capabilities` | object | No | [Linux capabilities](https://www.man7.org/linux/man-pages/man7/capabilities.7.html) to add or drop (e.g., `add`, `drop` arrays). | + +**Example**: + +```json +"securityContext": { + "privileged": false, + "runAsUser": 1001, + "runAsGroup": 3001, + "runAsNonRoot": true, + "readOnlyRootFilesystem": true, + "capabilities": { + "add": ["NET_ADMIN"], + "drop": ["ALL"] + } +} +``` + +#### Usage Notes and Best Practices + +- Use either command or execProcesses, but not both simultaneously. +- Clearly name containers to reflect their role or function. +- Limit environment variables to essential values; use regex sparingly. +- Mount volumes as read-only whenever possible for enhanced security. +- Keep fragments modular and scoped to specific capabilities. + +#### Azure Best Practices + +- Store container images securely in Azure Container Registry (ACR). +- Regularly update container images and fragments to patch vulnerabilities. +- Follow the principle of least privilege when defining container capabilities and permissions. +- Use Azure Managed Identities for secure access to Azure resources from containers. + +#### Full Example + +The most up to date example [can be found here](../../samples/config.json) diff --git a/src/confcom/azext_confcom/fragment_util.py b/src/confcom/azext_confcom/fragment_util.py index 54b70a00db7..7db83beb57e 100644 --- a/src/confcom/azext_confcom/fragment_util.py +++ b/src/confcom/azext_confcom/fragment_util.py @@ -3,21 +3,36 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import yaml import copy from typing import List + +import yaml +from azext_confcom import config, oras_proxy +from azext_confcom.errors import eprint +from azext_confcom.template_util import (case_insensitive_dict_get, + extract_containers_from_text, + extract_namespace_from_text, + extract_svn_from_text) from knack.log import get_logger -from azext_confcom import config -from azext_confcom import oras_proxy -from azext_confcom.cose_proxy import CoseSignToolProxy -from azext_confcom.template_util import ( - case_insensitive_dict_get, - extract_containers_from_text, -) logger = get_logger(__name__) +def sanitize_fragment_fields(fragments_list: List[dict]) -> List[dict]: + fields_to_keep = [ + config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED, + config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN, + config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_INCLUDES, + config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_ISSUER + ] + out_list = copy.deepcopy(fragments_list) + for fragment in out_list: + keys_to_remove = [key for key in fragment.keys() if key not in fields_to_keep] + for key in keys_to_remove: + fragment.pop(key, None) + return out_list + + # input is the full rego file as a string # output is all of the containers in the rego files as a list of dictionaries def combine_fragments_with_policy(all_fragments): @@ -35,58 +50,50 @@ def get_all_fragment_contents( fragment_imports: List[dict], ) -> List[str]: # was getting errors with pass by reference so we need to copy it - copied_fragment_imports = copy.deepcopy(fragment_imports) + remaining_fragments = copy.deepcopy(fragment_imports) - fragment_feeds = [ - case_insensitive_dict_get(fragment, config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED) - for fragment in copied_fragment_imports - ] + def remove_from_list_via_feed(fragment_import_list, feed): + for i, fragment_import in enumerate(fragment_import_list): + if fragment_import.get("feed") == feed: + fragment_import_list.pop(i) all_fragments_contents = [] # get all the image attached fragments for image in image_names: # TODO: make sure this doesn't error out if the images aren't in a registry. # This will probably be in the discover function - fragments, feeds = oras_proxy.pull_all_image_attached_fragments(image) - for fragment, feed in zip(fragments, feeds): - if feed in fragment_feeds: - all_fragments_contents.append(fragment) - else: - logger.warning("Fragment feed %s not in list of feeds to use. Skipping fragment.", feed) - - cose_proxy = CoseSignToolProxy() - # get all the local fragments - for fragment in copied_fragment_imports: - contents = [] - # pull locally if there is a path, otherwise pull from the remote registry - if ( - fragment.get(config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_PATH) - ): - contents = [ - cose_proxy.extract_payload_from_path( - fragment[config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_PATH] - ) + image_attached_fragments, feeds = oras_proxy.pull_all_image_attached_fragments(image) + for fragment, feed in zip(image_attached_fragments, feeds): + all_feeds = [ + case_insensitive_dict_get(temp_fragment, config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED) + for temp_fragment in remaining_fragments ] + feed_idx = all_feeds.index(feed) if feed in all_feeds else -1 - # add the new fragments to the list of all fragments if they're not already there - # the side effect of adding this way is that if we have a local path to a nested fragment - # we will pull then use the local version of the fragment instead of pulling from the registry - for content in contents: - fragment_text = extract_containers_from_text( - content, config.REGO_FRAGMENT_START - ).replace("\t", " ") + if feed_idx != -1: + import_statement = remaining_fragments[feed_idx] - fragments = yaml.load( - fragment_text, - Loader=yaml.FullLoader, - ) - - # this adds new feeds to the list of feeds to pull dynamically - # it will end when there are no longer nested fragments to pull - for new_fragment in fragments: - if new_fragment[config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED] not in fragment_feeds: - copied_fragment_imports.append(new_fragment) + if ( + int( + case_insensitive_dict_get( + import_statement, config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN + ) + ) <= extract_svn_from_text(fragment) + ): + remove_from_list_via_feed(remaining_fragments, feed) + all_fragments_contents.append(fragment) + else: + logger.warning("Fragment feed %s not in list of feeds to use. Skipping fragment.", feed) + # grab the remaining fragments which should be standalone + standalone_fragments, _ = oras_proxy.pull_all_standalone_fragments(remaining_fragments) + all_fragments_contents.extend(standalone_fragments) - all_fragments_contents.append(content) + # make sure there aren't conflicts in the namespaces + namespaces = set() + for fragment in all_fragments_contents: + namespace = extract_namespace_from_text(fragment) + if namespace in namespaces: + eprint("Duplicate namespace found: %s. This may cause issues.", namespace) + namespaces.add(namespace) return combine_fragments_with_policy(all_fragments_contents) diff --git a/src/confcom/azext_confcom/oras_proxy.py b/src/confcom/azext_confcom/oras_proxy.py index 28e06e6f118..26557437ae6 100644 --- a/src/confcom/azext_confcom/oras_proxy.py +++ b/src/confcom/azext_confcom/oras_proxy.py @@ -3,19 +3,25 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import subprocess import json +import os import platform import re -from knack.log import get_logger +import subprocess +from tempfile import mkdtemp from typing import List -from azext_confcom.errors import eprint -from azext_confcom.config import ARTIFACT_TYPE + +from azext_confcom.config import ARTIFACT_TYPE, DEFAULT_REGO_FRAGMENTS from azext_confcom.cose_proxy import CoseSignToolProxy -from azext_confcom.os_util import delete_silently +from azext_confcom.errors import eprint +from azext_confcom.os_util import clean_up_temp_folder, delete_silently +from azext_confcom.template_util import ( + extract_containers_and_fragments_from_text, extract_svn_from_text) +from knack.log import get_logger host_os = platform.system() machine = platform.machine() +SHA256_PREFIX = "@sha256:" logger = get_logger(__name__) @@ -38,7 +44,7 @@ def prepend_docker_registry(image_name: str) -> str: 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 ("/" not in name or "." not in name.split("/")[0]) and not name.startswith("localhost"): # If no registry is specified, assume docker.io/library if "/" not in name: # Add the `library` namespace for official images @@ -57,7 +63,8 @@ def call_oras_cli(args, check=False): # return their digests in a list if there are some def discover( image: str, -) -> List[str]: +) -> tuple[bool, List[str]]: + image_exists = True # normalize the name in case the docker registry is implied image = prepend_docker_registry(image) @@ -74,31 +81,62 @@ def discover( hashes.append(manifest["digest"]) # get the exit code from the subprocess else: - if "401: Unauthorized" in item.stderr.decode("utf-8"): - eprint( - f"Error pulling the policy fragment from {image}.\n\n" - + "Please log into the registry and try again.\n\n" + err_str = item.stderr.decode("utf-8") + if "401: Unauthorized" in err_str: + logger.warning( + "Error pulling the policy fragment from %s.\n\n" + + "Please log into the registry and try again.\n\n", + image ) - eprint(f"Error retrieving fragments from remote repo: {item.stderr.decode('utf-8')}", exit_code=item.returncode) - return hashes + image_exists = False + # this happens when the image isn't found in the remote repo or there is no access to the remote repo + elif f"{image}: not found" in err_str: + logger.warning("No policy fragments found for image %s", image) + image_exists = False + elif "dial tcp: lookup" in err_str: + logger.warning(f"Could not access registry for {image}") + image_exists = False + else: + eprint(f"Error retrieving fragments from remote repo: {err_str}", exit_code=item.returncode) + return image_exists, hashes -# pull the policy fragment from the remote repo and return its contents as a string def pull( - image: str, - image_hash: str, + artifact: str, + hash_val: str = "", + tag: str = "", ) -> str: - 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) + """ + pull the policy fragment from the remote repo and return its filepath after downloaded. + This file must be cleaned up after use. + """ + + full_path = "" + if SHA256_PREFIX in artifact: + artifact, temp_hash_val = artifact.split(SHA256_PREFIX) + if temp_hash_val != hash_val: + eprint(f"Input '{hash_val}' does not match what is present in registry '{temp_hash_val}'") + full_path = f"{artifact}{SHA256_PREFIX}{hash_val}" + elif artifact and hash_val: + # response from discover function includes "sha256:" but not "@" + full_path = f"{artifact}@{hash_val}" + elif ":" in artifact: + artifact, tag = artifact.rsplit(":", maxsplit=1) + full_path = f"{artifact}:{tag}" + else: + eprint(f"Invalid artifact name: {artifact}") + logger.info("Pulling fragment: %s", full_path) + + temp_folder = mkdtemp() + arg_list = ["oras", "pull", full_path, "-o", temp_folder] + item = call_oras_cli(arg_list, check=False) # get the exit code from the subprocess if item.returncode != 0: if "401: Unauthorized" in item.stderr.decode("utf-8"): eprint( - f"Error pulling the policy fragment: {image}@{image_hash}.\n\n" + f"Error pulling the policy fragment: {full_path}.\n\n" + "Please log into the registry and try again.\n\n" ) eprint(f"Error while pulling fragment: {item.stderr.decode('utf-8')}", exit_code=item.returncode) @@ -112,32 +150,75 @@ def pull( break if filename == "": - eprint(f"Could not find the filename of the pulled fragment for {image}@{image_hash}") - - return filename + eprint(f"Could not find the filename of the pulled fragment for {full_path}") + out_filename = os.path.join(temp_folder, filename) + return out_filename def pull_all_image_attached_fragments(image): # TODO: be smart about if we're pulling a fragment directly or trying to discover them from an image tag # TODO: this will be for standalone fragments - fragments = discover(image) + image_exists, fragments = discover(image) + fragment_contents = [] + feeds = [] + if image_exists: + proxy = CoseSignToolProxy() + for fragment_digest in fragments: + filename = pull(image, hash_val=fragment_digest) + text = proxy.extract_payload_from_path(filename) + feed = proxy.extract_feed_from_path(filename) + clean_up_temp_folder(filename) + fragment_contents.append(text) + feeds.append(feed) + + return fragment_contents, feeds + + +def create_list_of_standalone_imports(fragment_feeds): + # the output will be a list of dicts that will reflect the same output as pull_all_standalone_fragments + proxy = CoseSignToolProxy() + standalone_imports = [] + for feed in fragment_feeds: + filename = pull(artifact=feed) + standalone_import = proxy.generate_import_from_path(filename, minimum_svn=-1) + clean_up_temp_folder(filename) + standalone_imports.append(standalone_import) + return standalone_imports + + +def pull_all_standalone_fragments(fragment_imports): fragment_contents = [] feeds = [] proxy = CoseSignToolProxy() - for fragment_digest in fragments: - filename = pull(image, fragment_digest) - text = proxy.extract_payload_from_path(filename) - feed = proxy.extract_feed_from_path(filename) - # containers = extract_containers_from_text(text, REGO_CONTAINER_START) - # new_fragments = extract_containers_from_text(text, REGO_FRAGMENT_START) - # if new_fragments: - # for new_fragment in new_fragments: - # feed = new_fragment.get("feed") - # # if we don't have the feed in the list of feeds we've already pulled, pull it - # if feed not in fragment_feeds: - # fragment_contents.extend(pull_all_image_attached_fragments(feed, fragment_feeds=fragment_feeds)) - fragment_contents.append(text) + + for fragment in fragment_imports: + if fragment in DEFAULT_REGO_FRAGMENTS: + continue + path = fragment.get("path") + feed = fragment.get("feed") + minimum_svn = int(fragment.get("minimum_svn")) feeds.append(feed) + + if path: + text = proxy.extract_payload_from_path(path) + else: + filename = pull(artifact=feed) + text = proxy.extract_payload_from_path(filename) + svn = extract_svn_from_text(text) + if svn < minimum_svn: + logger.warning( + "found fragment %s but the svn of %s is lower than the the specified minimum_svn of %s", + feed, + svn, + minimum_svn + ) + continue + clean_up_temp_folder(filename) + # put new fragments to the end of the list + fragment_contents.append(text) + _, fragments = extract_containers_and_fragments_from_text(text) + fragment_imports.extend(fragments) + return fragment_contents, feeds @@ -177,18 +258,35 @@ def attach_fragment_to_image(image_name: str, filename: str): def generate_imports_from_image_name(image_name: str, minimum_svn: str) -> List[dict]: cose_proxy = CoseSignToolProxy() - fragment_hashes = discover(image_name) + image_exists, fragment_hashes = discover(image_name) import_list = [] - for fragment_hash in fragment_hashes: - filename = "" - try: - filename = pull(image_name, fragment_hash) - import_statement = cose_proxy.generate_import_from_path(filename, minimum_svn) - if import_statement not in import_list: - import_list.append(import_statement) - finally: - # clean up the fragment file - delete_silently(filename) + if image_exists: + for fragment_hash in fragment_hashes: + filename = "" + try: + filename = pull(image_name, fragment_hash) + import_statement = cose_proxy.generate_import_from_path(filename, minimum_svn) + if import_statement not in import_list: + import_list.append(import_statement) + finally: + # clean up the fragment file + delete_silently(filename) return import_list + + +def push_fragment_to_registry(feed_name: str, filename: str) -> None: + # push the fragment to the registry + arg_list = [ + "oras", + "push", + feed_name, + "--artifact-type", + ARTIFACT_TYPE, + filename + ":application/cose-x509+rego" + ] + item = call_oras_cli(arg_list, check=False) + if item.returncode != 0: + eprint(f"Could not push fragment to registry: {feed_name}. Failed with {item.stderr}") + print(f"Fragment pushed to registry '{feed_name}'") diff --git a/src/confcom/azext_confcom/os_util.py b/src/confcom/azext_confcom/os_util.py index 36b2d49cb07..5a5e8deb638 100644 --- a/src/confcom/azext_confcom/os_util.py +++ b/src/confcom/azext_confcom/os_util.py @@ -4,7 +4,7 @@ # -------------------------------------------------------------------------------------------- import base64 -from typing import List +from typing import List, Union import yaml import yaml.scanner import binascii @@ -12,11 +12,14 @@ import json import os import stat +from knack.log import get_logger from tarfile import TarFile from azext_confcom.errors import ( eprint, ) +logger = get_logger(__name__) + def bytes_to_base64(data: bytes) -> str: return base64.b64encode(data).decode("ascii") @@ -36,6 +39,13 @@ def base64_to_str(data: str) -> str: return data_str +def clean_up_temp_folder(temp_file_path: str) -> None: + # clean up the folder that the fragment was downloaded to + folder_name = os.path.dirname(temp_file_path) + logger.info("cleaning up folder with fragment: %s", folder_name) + shutil.rmtree(folder_name) + + def load_json_from_str(data: str) -> dict: if data: try: @@ -279,25 +289,31 @@ def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str): # 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) +def force_delete_silently(filename: Union[str, list[str]]) -> None: + if isinstance(filename, str): + filename = [filename] + for f in filename: + try: + os.chmod(f, stat.S_IWRITE) + except FileNotFoundError: + pass + except PermissionError: + eprint(f"Permission denied to edit file: {f}") + except OSError as e: + eprint(f"Error editing file: {f}, {e}") + delete_silently(f) # helper function to delete a file that may or may not exist -def delete_silently(filename: str) -> None: - try: - os.remove(filename) - except FileNotFoundError: - pass - except PermissionError: - eprint(f"Permission denied to delete file: {filename}") - except OSError as e: - eprint(f"Error deleting file: {filename}, {e}") +def delete_silently(filename: Union[str, list[str]]) -> None: + if isinstance(filename, str): + filename = [filename] + for f in filename: + try: + os.remove(f) + except FileNotFoundError: + pass + except PermissionError: + eprint(f"Permission denied to delete file: {f}") + except OSError as e: + eprint(f"Error deleting file: {f}, {e}") diff --git a/src/confcom/azext_confcom/security_policy.py b/src/confcom/azext_confcom/security_policy.py index 32328dc0da1..8ab29f52032 100644 --- a/src/confcom/azext_confcom/security_policy.py +++ b/src/confcom/azext_confcom/security_policy.py @@ -3,50 +3,45 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import copy import json import warnings -import copy -from typing import Any, List, Dict, Tuple, Union from enum import Enum, auto -import deepdiff -from knack.log import get_logger -from tqdm import tqdm -from azext_confcom import os_util -from azext_confcom import config -from azext_confcom.container import UserContainerImage, ContainerImage +from typing import Any, Dict, List, Tuple, Union +import deepdiff +from azext_confcom import config, os_util +from azext_confcom.container import ContainerImage, UserContainerImage from azext_confcom.errors import eprint -from azext_confcom.template_util import ( - extract_confidential_properties, - is_sidecar, - pretty_print_func, - print_func, - readable_diff, - case_insensitive_dict_get, - compare_env_vars, - get_values_for_params, - process_mounts, - process_configmap, - extract_probe, - extract_lifecycle_hook, - process_env_vars_from_template, - get_image_info, - get_tar_location_from_mapping, - get_diff_size, - process_env_vars_from_yaml, - convert_to_pod_spec, - get_volume_claim_templates, - filter_non_pod_resources, - decompose_confidential_properties, - process_env_vars_from_config, - process_mounts_from_config, - process_fragment_imports, - get_container_diff, - convert_config_v0_to_v1, - detect_old_format, -) +from azext_confcom.fragment_util import sanitize_fragment_fields +from azext_confcom.oras_proxy import create_list_of_standalone_imports from azext_confcom.rootfs_proxy import SecurityPolicyProxy - +from azext_confcom.template_util import (case_insensitive_dict_get, + compare_env_vars, + convert_config_v0_to_v1, + convert_to_pod_spec, + decompose_confidential_properties, + detect_old_format, + extract_confidential_properties, + extract_lifecycle_hook, extract_probe, + extract_standalone_fragments, + filter_non_pod_resources, + get_container_diff, get_diff_size, + get_image_info, + get_tar_location_from_mapping, + get_values_for_params, + get_volume_claim_templates, + is_sidecar, pretty_print_func, + print_func, process_configmap, + process_env_vars_from_config, + process_env_vars_from_template, + process_env_vars_from_yaml, + process_fragment_imports, + process_mounts, + process_mounts_from_config, + readable_diff) +from knack.log import get_logger +from tqdm import tqdm logger = get_logger() @@ -186,10 +181,12 @@ def get_serialized_output( return os_util.str_to_base64(policy_str) def generate_fragment(self, namespace: str, svn: str, output_type: int, omit_id: bool = False) -> str: + # get rid of fields that aren't strictly needed for the fragment import + sanitized_fragments = sanitize_fragment_fields(self.get_fragments()) return config.CUSTOMER_REGO_FRAGMENT % ( namespace, pretty_print_func(svn), - pretty_print_func(self.get_fragments()), + pretty_print_func(sanitized_fragments), self.get_serialized_output(output_type, rego_boilerplate=False, include_sidecars=False, omit_id=omit_id), ) @@ -200,9 +197,12 @@ def _add_rego_boilerplate(self, output: str) -> str: pretty_print_func(self._api_version), output ) + + # get rid of fields that aren't strictly needed for the fragment import + sanitized_fragments = sanitize_fragment_fields(self.get_fragments()) return config.CUSTOMER_REGO_POLICY % ( pretty_print_func(self._api_version), - pretty_print_func(self._fragments), + pretty_print_func(sanitized_fragments), output, pretty_print_func(self._allow_properties_access), pretty_print_func(self._allow_dump_stacks), @@ -582,10 +582,10 @@ def should_eliminate_container_covered_by_fragments(self, image): config.POLICY_FIELD_CONTAINERS_NAME ) fragment_image_id = fragment_image.get(config.ACI_FIELD_CONTAINERS_ID) - if ":" not in fragment_image: + if isinstance(fragment_image_id, str) and ":" not in fragment_image_id: fragment_image_id = f"{fragment_image_id}:latest" if ( - fragment_image_id == image.base + image.tag or + fragment_image_id == f"{image.base}:{image.tag}" or container_name == image.get_name() ): image_policy = image.get_policy_json() @@ -703,6 +703,16 @@ def load_policy_from_arm_template_str( if init_container_list: container_list.extend(init_container_list) + # these are standalone fragments coming from the ARM template itself + standalone_fragments = extract_standalone_fragments(container_group_properties) + if standalone_fragments: + standalone_fragment_imports = create_list_of_standalone_imports(standalone_fragments) + unique_imports = set(rego_imports) + for fragment in standalone_fragment_imports: + if fragment not in unique_imports: + rego_imports.append(fragment) + unique_imports.add(fragment) + try: existing_containers, fragments = extract_confidential_properties( container_group_properties @@ -895,6 +905,8 @@ def load_policy_from_json( output_containers = [] # 1) Parse incoming string as JSON policy_input_json = os_util.load_json_from_str(data) + if not isinstance(policy_input_json, dict): + eprint("Input JSON is not a valid dictionary") is_old_format = detect_old_format(policy_input_json) if is_old_format: @@ -910,6 +922,7 @@ def load_policy_from_json( ) if not version: + version = "1.0" policy_input_json[config.ACI_FIELD_VERSION] = "1.0" rego_fragments = case_insensitive_dict_get( @@ -921,9 +934,16 @@ def load_policy_from_json( ) or "" # 3) Process rego_fragments + standalone_rego_fragments = case_insensitive_dict_get( + policy_input_json, config.ACI_FIELD_TEMPLATE_STANDALONE_REGO_FRAGMENTS + ) + if rego_fragments: process_fragment_imports(rego_fragments) + if standalone_rego_fragments: + rego_fragments.extend(standalone_rego_fragments) + if not input_containers and not rego_fragments: eprint( f'Field ["{config.ACI_FIELD_CONTAINERS}"]' + diff --git a/src/confcom/azext_confcom/template_util.py b/src/confcom/azext_confcom/template_util.py index cbaade26e8a..87ded36a257 100644 --- a/src/confcom/azext_confcom/template_util.py +++ b/src/confcom/azext_confcom/template_util.py @@ -26,7 +26,8 @@ # make this global so it can be used in multiple functions PARAMETER_AND_VARIABLE_REGEX = r"\[(?:parameters|variables)\(\s*'([^\.\/]+?)'\s*\)\]" WHOLE_PARAMETER_AND_VARIABLE = r"(\s*\[\s*(parameters|variables))(\(\s*'([^\.\/]+?)'\s*\)\])" - +SVN_PATTERN = r'svn\s*:=\s*"(\d+)"' +NAMESPACE_PATTERN = r'package\s+([a-zA-Z_][a-zA-Z0-9_]*)' class DockerClient: _client = None @@ -558,7 +559,7 @@ def process_fragment_imports(rego_imports) -> None: eprint( f'Field ["{config.ACI_FIELD_CONTAINERS}"]' + f'["{config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN}"] ' - + "can only be an integer value." + + "can only be a string with an integer value." ) includes = case_insensitive_dict_get( @@ -574,6 +575,40 @@ def process_fragment_imports(rego_imports) -> None: return rego_imports +def process_standalone_fragments(standalone_fragments: List[str]) -> Tuple[List[str], List[str]]: + fragment_contents = [] + feeds = [] + + for fragment in standalone_fragments: + feed = case_insensitive_dict_get( + fragment, config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED + ) + if not isinstance(feed, str): + eprint( + f'Field ["{config.ACI_FIELD_CONTAINERS}"]' + + f'["{config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED}"] ' + + "can only be a string value." + ) + + filename = case_insensitive_dict_get( + fragment, config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FILE + ) + if not isinstance(filename, str): + eprint( + f'Field ["{config.ACI_FIELD_CONTAINERS}"]' + + f'["{config.POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FILE}"] ' + + "can only be a string value." + ) + + with open(filename, "r", encoding="utf-8") as file: + text = file.read() + + fragment_contents.append(text) + feeds.append(feed) + + return fragment_contents, feeds + + def process_mounts(image_properties: dict, volumes: List[dict]) -> List[Dict[str, str]]: mount_source_table_keys = config.MOUNT_SOURCE_TABLE.keys() # initialize empty array of mounts @@ -1013,6 +1048,24 @@ def extract_containers_from_text(text, start) -> str: return ending[:count] +def extract_standalone_fragments( + container_group_properties, +) -> List[str]: + # extract the existing cce policy if that's what was being asked + confidential_compute_properties = case_insensitive_dict_get( + container_group_properties, config.ACI_FIELD_TEMPLATE_CONFCOM_PROPERTIES + ) + + if confidential_compute_properties is None: + return [] + + # in the ARM template, this is a list of references (strings) to OCI registries + standalone_fragments = case_insensitive_dict_get( + confidential_compute_properties, config.ACI_FIELD_TEMPLATE_STANDALONE_REGO_FRAGMENTS + ) or [] + return standalone_fragments + + def extract_confidential_properties( container_group_properties, ) -> Tuple[List[Dict], List[Dict]]: @@ -1074,6 +1127,51 @@ def extract_containers_and_fragments_from_text(text: str) -> Tuple[List[Dict], L return (containers, fragments) +def extract_svn_from_text(text: str) -> int: + """Extract SVN value from text using regex pattern matching. + + Args: + text: The input text containing the SVN definition + + Returns: + int: The SVN value + """ + # Pattern matches: svn := "123" or svn := "1" + match = re.search(SVN_PATTERN, text) + + if not match: + eprint("SVN value not found in the input text.") + + try: + return int(match.group(1)) + except (AttributeError, ValueError, IndexError): + eprint("Unable to extract valid SVN value from the text.") + + +def extract_namespace_from_text(text: str) -> str: + """Extract namespace value from text by finding text after 'package' keyword. + + Args: + text: The input text containing the namespace definition + + Returns: + str: The namespace value + """ + # Find the package declaration line + lines = text.split('\n') + for line in lines: + stripped_line = line.strip() + beginning = 'package ' + if stripped_line.startswith(beginning): + # Extract everything after 'package ' (first whitespace) + namespace = stripped_line[len(beginning):].strip() + if namespace: + return namespace + + eprint("Namespace value not found in the input text.") + return None + + # making these lambda print functions looks cleaner than having "json.dumps" 6 times def print_func(x: dict) -> str: return json.dumps(x, separators=(",", ":"), sort_keys=True) 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 7a4a06ca873..c1588c3dadd 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -3,40 +3,36 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import os -import unittest import json +import os import subprocess -from knack.util import CLIError - -from azext_confcom.security_policy import ( - UserContainerImage, - OutputType, - load_policy_from_json -) - -from azext_confcom.cose_proxy import CoseSignToolProxy +import tempfile +import time +import unittest +from tarfile import TarFile import azext_confcom.config as config +import docker +import requests +from azext_confcom.cose_proxy import CoseSignToolProxy +from azext_confcom.custom import acifragmentgen_confcom, acipolicygen_confcom +from azext_confcom.errors import AccContainerError +from azext_confcom.oras_proxy import pull, push_fragment_to_registry +from azext_confcom.os_util import (delete_silently, force_delete_silently, + load_json_from_file, load_json_from_str, + load_str_from_file, str_to_base64, + write_str_to_file) +from azext_confcom.security_policy import (OutputType, UserContainerImage, + load_policy_from_json) 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, - load_json_from_file, - 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 + DockerClient, case_insensitive_dict_get, decompose_confidential_properties, + extract_containers_and_fragments_from_text) +from azext_confcom.tests.latest.test_confcom_tar import create_tar_file from azure.cli.testsdk import ScenarioTest +from knack.util import CLIError TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +SAMPLES_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", '..', '..', '..', 'samples')) class FragmentMountEnforcement(unittest.TestCase): custom_json = """ @@ -231,9 +227,7 @@ def test_virtual_node_policy_fragment_generation(self): 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") - + force_delete_silently([fragment_filename, f"{rego_filename}.rego"]) class FragmentGenerating(unittest.TestCase): custom_json = """ @@ -465,6 +459,61 @@ def test_fragment_injected_sidecar_container_msi(self): self.assertEqual(image._workingDir, expected_workingdir) +class FragmentPolicyGeneratingTarfile(unittest.TestCase): + custom_json= """ + { + "version" : "1.0", + "containers": [ + { + "name": "simple-container", + "properties": { + "image": "mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64", + "environmentVariables": [ + { + "name": "PORT", + "value": "8080" + } + ], + "command": ["/bin/bash","-c","while sleep 5; do cat /mnt/input/access.log; done"], + "mounts": null + } + } + ] + } + """ + aci_policy = None + + @classmethod + def setUpClass(cls) -> None: + path = os.path.dirname(__file__) + cls.path = path + + def test_tar_file_fragment(self): + try: + with tempfile.TemporaryDirectory() as folder: + filename = os.path.join(folder, "oci.tar") + filename2 = os.path.join(self.path, "oci2.tar") + + tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename2} + create_tar_file(filename) + with TarFile(filename, "r") as tar: + tar.extractall(path=folder) + + with TarFile.open(filename2, 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) + + with load_policy_from_json(self.custom_json) as aci_policy: + aci_policy.populate_policy_content_for_all_images( + tar_mapping=tar_mapping_file + ) + + clean_room_fragment_text = aci_policy.generate_fragment("payload", "1", OutputType.RAW) + self.assertIsNotNone(clean_room_fragment_text) + except Exception as e: + raise AccContainerError("Could not get image from tar file") from e + + class FragmentPolicyGeneratingDebugMode(unittest.TestCase): custom_json = """ { @@ -606,16 +655,6 @@ class FragmentPolicySigning(unittest.TestCase): custom_json = """ { "version": "1.0", - "fragments": [ - { - "issuer": "did:x509:0:sha256:I__iuL25oXEVFdTP_aBLx_eT1RPHbCQ_ECBQfYZpt9s::eku:1.3.6.1.4.1.311.76.59.1.3", - "feed": "contoso.azurecr.io/infra", - "minimum_svn": "1", - "includes": [ - "containers" - ] - } - ], "containers": [ { "name": "my-image", @@ -662,7 +701,7 @@ class FragmentPolicySigning(unittest.TestCase): { "name": "my-image", "properties": { - "image": "mcr.microsoft.com/cbl-mariner/busybox:1.35", + "image": "mcr.microsoft.com/azurelinux/busybox:1.36", "execProcesses": [ { "command": [ @@ -722,7 +761,7 @@ class FragmentPolicySigning(unittest.TestCase): """ @classmethod def setUpClass(cls): - cls.key_dir_parent = os.path.join(TEST_DIR, '..', '..', '..', 'samples', 'certs') + cls.key_dir_parent = os.path.join(SAMPLES_DIR, '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): @@ -771,8 +810,7 @@ def test_signing(self): except Exception as e: raise e finally: - delete_silently(filename) - delete_silently(out_path) + delete_silently([filename, out_path]) def test_generate_import(self): filename = "payload4.rego" @@ -806,13 +844,12 @@ def test_generate_import(self): except Exception as e: raise e finally: - delete_silently(filename) - delete_silently(out_path) + delete_silently([filename, out_path]) def test_local_fragment_references(self): filename = "payload2.rego" filename2 = "payload3.rego" - fragment_json = "fragment.json" + fragment_json = "fragment_local.json" feed = "test_feed" feed2 = "test_feed2" algo = "ES384" @@ -851,16 +888,13 @@ def test_local_fragment_references(self): rego_str = load_str_from_file(filename2) # see if the import statement is in the rego file self.assertTrue("test_feed" in rego_str) + self.assertTrue(out_path not 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.9" 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) + delete_silently([filename, out_path, filename2, out_path2, fragment_json]) class FragmentVirtualNode(unittest.TestCase): @@ -895,8 +929,7 @@ class FragmentVirtualNode(unittest.TestCase): } ] } - """ - +""" aci_policy = None @classmethod @@ -939,35 +972,1196 @@ def test_fragment_vn2_workload_identity_mounts(self): default_mounts = [i.get('mountPath') for i in config.DEFAULT_MOUNTS_WORKLOAD_IDENTITY_VIRTUAL_NODE] for default_mount in default_mounts: self.assertIn(default_mount, mount_destinations) +class FragmentRegistryInteractions(ScenarioTest): + custom_json = """ +{ + "version": "1.0", + "fragments": [ + ], + "containers": [ + { + "name": "my-image2", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "execProcesses": [ + { + "command": [ + "echo", + "Hello World" + ] + } + ], + "volumeMounts": [ + { + "name": "azurefile", + "mountPath": "/mount/azurefile", + "mountType": "azureFile", + "readOnly": true + } + ], + "environmentVariables": [ + { + "name": "PATH", + "value": "/customized/path/value" + }, + { + "name": "TEST_REGEXP_ENV", + "value": "test_regexp_env(.*)", + "regex": true + } + ] + } + } + ] +} + """ -class InitialFragmentErrors(ScenarioTest): - def test_invalid_input(self): - with self.assertRaises(CLIError) as wrapped_exit: - self.cmd("az confcom acifragmentgen --image mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 -i fakepath/parameters.json --namespace fake_namespace --svn 1") - self.assertEqual(wrapped_exit.exception.args[0], "Must provide either an image name or an input file to generate a fragment") + custom_json2 = """ +{ + "version": "1.0", + "fragments": [ + ], + "containers": [ + { + "name": "my-image", + "properties": { + "image": "mcr.microsoft.com/azurelinux/busybox:1.36", + "execProcesses": [ + { + "command": [ + "sleep", + "infinity" + ] + } + ], + "environmentVariables": [ + { + "name": "PATH", + "value": "/another/customized/path/value" + }, + { + "name": "TEST_REGEXP_ENV2", + "value": "test_regexp_env2(.*)", + "regex": true + } + ] + } + }, + { + "name": "my-image2", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "execProcesses": [ + { + "command": [ + "echo", + "Hello World" + ] + } + ], + "volumeMounts": [ + { + "name": "azurefile", + "mountPath": "/mount/azurefile", + "mountType": "azureFile", + "readOnly": true + } + ], + "environmentVariables": [ + { + "name": "PATH", + "value": "/customized/path/value" + }, + { + "name": "TEST_REGEXP_ENV", + "value": "test_regexp_env(.*)", + "regex": true + } + ] + } + } + ] +} +""" - with self.assertRaises(CLIError) as wrapped_exit: - self.cmd("az confcom acifragmentgen --generate-import --minimum-svn 1") - self.assertEqual(wrapped_exit.exception.args[0], "Must provide either a fragment path, an input file, or " + - "an image name to generate an import statement") + custom_json3 = """ + { + "version": "1.0", + "fragments": [ + ], + "containers": [ + { + "name": "my-image", + "properties": { + "image": "localhost:5000/helloworld:2.9", + "execProcesses": [ + { + "command": [ + "echo", + "Hello World" + ] + } + ], + "volumeMounts": [ + { + "name": "azurefile", + "mountPath": "/mount/azurefile", + "mountType": "azureFile", + "readOnly": true + } + ], + "environmentVariables": [ + { + "name": "PATH", + "value": "/customized/path/value" + }, + { + "name": "TEST_REGEXP_ENV", + "value": "test_regexp_env(.*)", + "regex": true + } + ] + } + } + ] + } + """ - with self.assertRaises(CLIError) as wrapped_exit: - self.cmd("az confcom acifragmentgen --image mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 -k fakepath/key.pem --namespace fake_namespace --svn 1") - self.assertEqual(wrapped_exit.exception.args[0], "Must provide both --key and --chain to sign a fragment") + @classmethod + def setUpClass(cls): + # start the zot registry + cls.zot_image = "ghcr.io/project-zot/zot-linux-amd64:latest" + cls.registry = "localhost:5000" + registry_name = "myregistry" - with self.assertRaises(CLIError) as wrapped_exit: - self.cmd("az confcom acifragmentgen --fragment-path ./fragment.json --image mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 --namespace fake_namespace --svn 1 --minimum-svn 1") - self.assertEqual(wrapped_exit.exception.args[0], "Must provide --generate-import to specify a fragment path") + # Initialize Docker client + try: + with DockerClient() as client: + client.images.pull(cls.zot_image) + + # Replace output = subprocess.run("docker ps -a", capture_output=True, shell=True) + # Check if container already exists + existing_containers = client.containers.list(all=True, filters={"name": registry_name}) + + # Replace subprocess.run(f"docker run --name {registry_name} -d -p 5000:5000 {cls.zot_image}", shell=True) + if not existing_containers: + try: + client.containers.run( + cls.zot_image, + name=registry_name, + ports={'5000/tcp': 5000}, + detach=True + ) + except docker.errors.APIError as e: + raise Exception(f"Error starting registry container: {e}") + else: + # Start the container if it exists but is not running + container = existing_containers[0] + if container.status != 'running': + container.start() + except docker.errors.DockerException as e: + raise Exception(f"Docker is not available: {e}") + + # Replace subprocess.run(f"docker pull {cls.zot_image}", shell=True) + except docker.errors.ImageNotFound: + raise Exception(f"Could not pull image {cls.zot_image}") + except docker.errors.APIError as e: + raise Exception(f"Error pulling image {cls.zot_image}: {e}") + + cls.key_dir_parent = os.path.join(SAMPLES_DIR, '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') - with self.assertRaises(CLIError) as wrapped_exit: - self.cmd("az confcom acifragmentgen --input ./input.json --namespace example --svn -1") - self.assertEqual(wrapped_exit.exception.args[0], "--svn must be an integer") + arg_list = [ + script_path, + ] + os.chmod(script_path, 0o755) - with self.assertRaises(CLIError) as wrapped_exit: - self.cmd("az confcom acifragmentgen --input ./input.json --namespace policy --svn 1") - self.assertEqual(wrapped_exit.exception.args[0], "Namespace 'policy' is reserved") + # 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(), + ) - with self.assertRaises(CLIError) as wrapped_exit: - self.cmd("az confcom acifragmentgen --algo fake_algo --key ./key.pem --chain ./cert-chain.pem --namespace example --svn 1 -i ./input.json") - self.assertEqual(wrapped_exit.exception.args[0], f"Algorithm 'fake_algo' is not supported. Supported algorithms are {config.SUPPORTED_ALGOS}") \ No newline at end of file + if item.returncode != 0: + raise Exception("Error creating certificate chain") + + with load_policy_from_json(cls.custom_json) as aci_policy: + aci_policy.populate_policy_content_for_all_images() + cls.aci_policy = aci_policy + with load_policy_from_json(cls.custom_json2) as aci_policy2: + aci_policy2.populate_policy_content_for_all_images() + cls.aci_policy2 = aci_policy2 + + # stall while we wait for the registry to start running + result = requests.get(f"http://{cls.registry}/v2/_catalog") + counter = 0 + retry_limit = 10 + while result.status_code != 200: + time.sleep(1) + result = requests.get(f"http://{cls.registry}/v2/_catalog") + counter += 1 + if counter == retry_limit: + raise Exception("Could not start local registry in time") + + + def test_generate_import_from_remote(self): + filename = "payload5.rego" + feed = f"{self.registry}/test_feed:test_tag" + algo = "ES384" + out_path = filename + ".cose" + + fragment_text = self.aci_policy.generate_fragment("payload4", "1", OutputType.RAW) + temp_filename = "temp.json" + 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) + push_fragment_to_registry(feed, out_path) + + # this should download and create the import statement + acifragmentgen_confcom(None, None, None, None, None, None, None, None, "1", generate_import=True, fragment_path=feed, fragments_json=temp_filename) + import_file = load_json_from_file(temp_filename) + import_statement = import_file.get(config.ACI_FIELD_CONTAINERS_REGO_FRAGMENTS)[0] + + 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, out_path, temp_filename]) + + def test_remote_fragment_references(self): + filename = "payload6.rego" + filename2 = "payload7.rego" + first_fragment = "first_fragment.json" + fragment_json = "fragment_remote.json" + feed = f"{self.registry}/test_feed:v1" + feed2 = f"{self.registry}/test_feed2:v2" + out_path = filename + ".cose" + out_path2 = filename2 + ".cose" + + # fragment_text = self.aci_policy.generate_fragment("payload6", 1, OutputType.RAW) + + try: + write_str_to_file(first_fragment, self.custom_json) + write_str_to_file(fragment_json, self.custom_json2) + acifragmentgen_confcom( + None, first_fragment, None, "payload7", "1", feed, self.key, self.chain, None, output_filename=filename + ) + + # 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 + ) + + push_fragment_to_registry(feed, out_path) + + acifragmentgen_confcom( + None, fragment_json, None, "payload7", "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(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.9" in rego_str) + except Exception as e: + raise e + finally: + delete_silently([filename, out_path, filename2, out_path2, fragment_json, first_fragment]) + + def test_incorrect_minimum_svn(self): + filename = "payload8.rego" + filename2 = "payload9.rego" + fragment_json = "fragment.json" + feed = f"{self.registry}/test_feed:v3" + feed2 = f"{self.registry}/test_feed2:v4" + algo = "ES384" + out_path = filename + ".cose" + out_path2 = filename2 + ".cose" + + fragment_text = self.aci_policy.generate_fragment("payload8", "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="2", fragments_json=fragment_json, fragment_path=out_path + ) + # put the "path" field into the import statement + push_fragment_to_registry(feed, out_path) + acifragmentgen_confcom( + None, fragment_json, None, "payload9", "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 is in the second fragment because the svn prevents usage + self.assertTrue("mcr.microsoft.com/acc/samples/aci/helloworld:2.9" in rego_str) + except Exception as e: + raise e + finally: + delete_silently([filename, out_path, filename2, out_path2, fragment_json]) + + def test_image_attached_fragment_coverage(self): + # Initialize Docker client + try: + with DockerClient() as client: + # Replace subprocess.run(f"docker tag mcr.microsoft.com/acc/samples/aci/helloworld:2.9 {self.registry}/helloworld:2.9", shell=True) + try: + source_image = client.images.get("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") + source_image.tag(f"{self.registry}/helloworld:2.9") + except docker.errors.ImageNotFound: + # Try to pull the image first + try: + client.images.pull("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") + source_image = client.images.get("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") + source_image.tag(f"{self.registry}/helloworld:2.9") + except docker.errors.APIError as e: + raise Exception(f"Could not pull or tag image: {e}") + except docker.errors.APIError as e: + raise Exception(f"Error tagging image: {e}") + + # Replace subprocess.run(f"docker push {self.registry}/helloworld:2.9", timeout=30, shell=True) + try: + # Note: Docker SDK push returns a generator of status updates + push_logs = client.images.push(f"{self.registry}/helloworld:2.9", stream=True, decode=True) + # Consume the generator to ensure push completes + for log in push_logs: + if 'error' in log: + raise Exception(f"Push failed: {log['error']}") + except docker.errors.APIError as e: + raise Exception(f"Error pushing image: {e}") + except docker.errors.DockerException as e: + raise Exception(f"Docker is not available: {e}") + + filename = "container_image_attached.json" + rego_filename = "temp_namespace" + try: + write_str_to_file(filename, self.custom_json3) + acifragmentgen_confcom( + None, + filename, + None, + rego_filename, + "1", + "temp_feed", + self.key, + self.chain, + "1", + f"{self.registry}/helloworld:2.9", + upload_fragment=True, + ) + + + # this will insert the import statement into the original container.json + acifragmentgen_confcom( + f"{self.registry}/helloworld:2.9", None, None, None, None, None, None, None, generate_import=True, minimum_svn="1", fragments_json=filename + ) + + # try to generate the policy again to make sure there are no containers in the resulting rego + with self.assertRaises(SystemExit) as exc_info: + acifragmentgen_confcom( + None, + filename, + None, + "temp_namespace2", + "1", + "temp_feed2", + None, + None, + "1", + f"{self.registry}/helloworld:2.9", + ) + self.assertEqual(exc_info.exception.code, 1) + + except Exception as e: + raise e + finally: + force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}.rego.cose"]) + + def test_incorrect_pull_location(self): + with self.assertRaises(SystemExit) as exc_info: + _ = pull(f"{self.registry}/fake_artifact") + self.assertEqual(exc_info.exception.code, 1) + + def test_reserved_namespace(self): + filename = "container_image_attached2.json" + rego_filename = "policy" + with self.assertRaises(CLIError) as exc_info: + try: + write_str_to_file(filename, self.custom_json) + self.cmd(f"confcom acifragmentgen -i {filename} --namespace policy --svn 1") + except Exception as e: + raise e + finally: + force_delete_silently([filename, f"{rego_filename}.rego"]) + + def test_invalid_svn(self): + filename = "container_image_attached3.json" + rego_filename = "myfile" + with self.assertRaises(CLIError) as exc_info: + try: + write_str_to_file(filename, self.custom_json) + self.cmd(f"confcom acifragmentgen -i {filename} --namespace policy --svn 0") + except Exception as e: + raise e + finally: + force_delete_silently([filename, f"{rego_filename}.rego"]) + +class ExtendedFragmentTests(ScenarioTest): + """Extended test cases for fragment operations following the requested scenarios.""" + + custom_json_multi_container = """ +{ + "version": "1.0", + "fragments": [], + "containers": [ + { + "name": "first-container", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "execProcesses": [ + { + "command": ["echo", "First Container"] + } + ], + "environmentVariables": [ + { + "name": "CONTAINER_TYPE", + "value": "first" + } + ] + } + }, + { + "name": "second-container", + "properties": { + "image": "mcr.microsoft.com/azurelinux/busybox:1.36", + "execProcesses": [ + { + "command": ["echo", "Second Container"] + } + ], + "environmentVariables": [ + { + "name": "CONTAINER_TYPE", + "value": "second" + } + ] + } + } + ] +} + """ + + custom_json_stdio_disabled = """ +{ + "version": "1.0", + "fragments": [], + "containers": [ + { + "name": "stdio-disabled-container", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "execProcesses": [ + { + "command": ["echo", "No stdio access"] + } + ], + "environmentVariables": [ + { + "name": "STDIO_DISABLED", + "value": "true" + } + ] + } + } + ] +} + """ + + @classmethod + def setUpClass(cls): + cls.registry = "localhost:5000" + cls.key_dir_parent = os.path.join(SAMPLES_DIR, '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') + + def test_upload_signed_fragment_to_registry(self): + """Test uploading a signed fragment to the registry.""" + filename = "signed_fragment.rego" + feed = f"{self.registry}/signed_fragment:v1" + algo = "ES384" + out_path = filename + ".cose" + + try: + # Create a simple fragment policy + with load_policy_from_json(self.custom_json_multi_container) as policy: + policy.populate_policy_content_for_all_images() + fragment_text = policy.generate_fragment("signed_fragment", "1", OutputType.RAW) + + write_str_to_file(filename, fragment_text) + + # Sign and upload to registry + cose_proxy = CoseSignToolProxy() + iss = cose_proxy.create_issuer(self.chain) + cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path) + + # Upload to registry + push_fragment_to_registry(feed, out_path) + + # Verify we can pull it back + pulled_fragment = pull(feed) + self.assertIsNotNone(pulled_fragment) + + except Exception as e: + raise e + finally: + force_delete_silently([filename, out_path]) + + def test_attach_fragment_to_different_image(self): + """Test attaching a fragment to a different image than the one it was created for.""" + filename = "different_image_fragment.json" + rego_filename = "different_image_rego" + + try: + write_str_to_file(filename, self.custom_json_multi_container) + + # Create fragment for first image but try to attach to second + acifragmentgen_confcom( + None, + filename, + None, + rego_filename, + "1", + "test_feed_different", + self.key, + self.chain, + "1", + "mcr.microsoft.com/azurelinux/busybox:1.36", # Different from the one in JSON + upload_fragment=False, + ) + + # Verify the fragment was created + self.assertTrue(os.path.exists(f"{rego_filename}.rego")) + + except Exception as e: + raise e + finally: + force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}.rego.cose"]) + + def test_remote_pull_failure_path(self): + """Test failure path when trying to pull from non-existent registry location.""" + with self.assertRaises(SystemExit) as exc_info: + _ = pull(f"{self.registry}/nonexistent_fragment:v1") + self.assertEqual(exc_info.exception.code, 1) + + def test_mixed_fragments_and_standalone_fragments_import(self): + """Test import JSON with both 'fragments' and 'standaloneFragments' sections.""" + mixed_json = """ +{ + "version": "1.0", + "fragments": [ + { + "feed": "localhost:5000/fragment1:v1", + "issuer": "test_issuer", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + } + ], + "standaloneFragments": [ + { + "feed": "localhost:5000/standalone1:v1", + "issuer": "test_issuer", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + } + ], + "containers": [] +} + """ + + filename = "mixed_fragments.json" + try: + write_str_to_file(filename, mixed_json) + + # This should handle both fragment types + with load_policy_from_json(mixed_json) as policy: + policy.populate_policy_content_for_all_images() + output = policy.generate_fragment("mixed_fragments", "1", OutputType.RAW) + self.assertIsNotNone(output) + + except Exception as e: + raise e + finally: + force_delete_silently(filename) + + def test_import_json_as_array(self): + """Test import JSON that is just an array instead of object.""" + array_json = """ +[ + { + "feed": "localhost:5000/array_fragment:v1", + "issuer": "test_issuer", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + } +] + """ + + filename = "array_import.json" + try: + write_str_to_file(filename, array_json) + + # This should fail or handle gracefully + with self.assertRaises((ValueError, CLIError, SystemExit)): + with load_policy_from_json(array_json) as policy: + policy.populate_policy_content_for_all_images() + + except Exception as e: + raise e + finally: + force_delete_silently(filename) + + def test_disable_stdio_access(self): + """Test fragment generation with stdio access disabled.""" + filename = "stdio_disabled.json" + rego_filename = "stdio_disabled_rego" + + try: + write_str_to_file(filename, self.custom_json_stdio_disabled) + + acifragmentgen_confcom( + None, + filename, + None, + rego_filename, + "1", + "stdio_test_feed", + None, + None, + None, + disable_stdio=True, + ) + + # Verify stdio access is disabled in the generated policy + rego_content = load_str_from_file(f"{rego_filename}.rego") + containers, _ = decompose_confidential_properties(str_to_base64(rego_content)) + + # Check that stdio access is disabled + self.assertFalse(containers[0].get("allow_stdio_access", True)) + + except Exception as e: + raise e + finally: + force_delete_silently([filename, f"{rego_filename}.rego"]) + + def test_tar_input_processing(self): + """Test processing tar and tar-mapping inputs.""" + tar_filename = "test_input.tar" + mapping_filename = "test_mapping.json" + + try: + # Create a simple tar mapping + tar_mapping = { + "tar_file": tar_filename, + "containers": [ + { + "name": "tar-container", + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9" + } + ] + } + + write_str_to_file(mapping_filename, json.dumps(tar_mapping)) + + # Create empty tar file for testing + with open(tar_filename, 'wb') as f: + f.write(b'') + + # This should handle tar input gracefully or fail with appropriate error + with self.assertRaises((FileNotFoundError, CLIError, SystemExit)): + acifragmentgen_confcom( + None, + mapping_filename, + tar_filename, + "tar_test_rego", + "1", + "tar_test_feed", + None, + None, + None + ) + + except Exception as e: + raise e + finally: + force_delete_silently([tar_filename, mapping_filename]) + + def test_fragment_target_image_consistency(self): + """Test that fragment always lands on the intended image with and without --image-target.""" + filename = "target_consistency.json" + rego_filename = "target_consistency_rego" + target_image = "mcr.microsoft.com/acc/samples/aci/helloworld:2.9" + + try: + write_str_to_file(filename, self.custom_json_multi_container) + + # Test with explicit image target + acifragmentgen_confcom( + None, + filename, + None, + rego_filename, + "1", + "target_test_feed", + None, + None, + "1", + target_image + ) + + # Verify the fragment contains the correct image + rego_content = load_str_from_file(f"{rego_filename}.rego") + self.assertIn(target_image, rego_content) + + # Test without explicit image target (should use from JSON) + acifragmentgen_confcom( + None, + filename, + None, + f"{rego_filename}_no_target", + "1", + "target_test_feed_2", + None, + None, + None + ) + + # Should contain images from JSON + rego_content_2 = load_str_from_file(f"{rego_filename}_no_target.rego") + self.assertIn("mcr.microsoft.com/acc/samples/aci/helloworld:2.9", rego_content_2) + + except Exception as e: + raise e + finally: + force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}_no_target.rego"]) + + def test_two_imports_same_feed_different_namespaces(self): + """Test two imports that reference the same feed but expect different namespaces.""" + # Note: This is actually testing an edge case where the same feed is referenced + # but the system might expect different namespace handling + container_json = """ +{ + "version": "1.0", + "containers": [ + { + "name": "shared-feed-container", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "execProcesses": [ + { + "command": ["echo", "Shared Feed Test"] + } + ], + "environmentVariables": [ + { + "name": "SHARED_FEED_TEST", + "value": "true" + } + ] + } + } + ] +} + """ + + feed = f"{self.registry}/shared_feed:v1" + namespace = "actual_namespace" + + fragment_file = "shared_feed_fragment.rego" + container_file = "shared_feed_container.json" + import_file = "same_feed_diff_namespace.json" + + try: + write_str_to_file(container_file, container_json) + + # Create and push fragment with a specific namespace + with load_policy_from_json(container_json) as policy: + policy.populate_policy_content_for_all_images() + fragment_text = policy.generate_fragment(namespace, "1", OutputType.RAW) + + write_str_to_file(fragment_file, fragment_text) + + # Sign and push fragment + cose_proxy = CoseSignToolProxy() + iss = cose_proxy.create_issuer(self.chain) + cose_proxy.cose_sign(fragment_file, self.key, self.chain, feed, iss, "ES384", fragment_file + ".cose") + push_fragment_to_registry(feed, fragment_file + ".cose") + + # Create import JSON that references the same feed twice + # The system should handle this gracefully since it's the same fragment + import_json = f""" +{{ + "version": "1.0", + "fragments": [ + {{ + "feed": "{feed}", + "issuer": "{iss}", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + }}, + {{ + "feed": "{feed}", + "issuer": "{iss}", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + }} + ], + "containers": [] +}} + """ + + write_str_to_file(import_file, import_json) + + # This should work since it's the same feed/fragment being referenced twice + with load_policy_from_json(import_json) as policy: + policy.populate_policy_content_for_all_images() + output = policy.generate_fragment("same_feed_diff_namespace", "1", OutputType.RAW) + self.assertIsNotNone(output) + + except Exception as e: + raise e + finally: + force_delete_silently([container_file, fragment_file, fragment_file + ".cose", import_file]) + + def test_two_imports_same_feed_and_namespace(self): + """Test two imports that share both feed and namespace.""" + # Create a single fragment and try to import it twice + container_json = """ +{ + "version": "1.0", + "containers": [ + { + "name": "duplicate-test-container", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "execProcesses": [ + { + "command": ["echo", "Duplicate Test"] + } + ], + "environmentVariables": [ + { + "name": "DUPLICATE_TEST", + "value": "true" + } + ] + } + } + ] +} + """ + + feed = f"{self.registry}/duplicate_feed:v1" + namespace = "duplicate_namespace" + + fragment_file = "duplicate_fragment.rego" + container_file = "duplicate_container.json" + import_file = "duplicate_imports.json" + + try: + write_str_to_file(container_file, container_json) + + # Create and push fragment + with load_policy_from_json(container_json) as policy: + policy.populate_policy_content_for_all_images() + fragment_text = policy.generate_fragment(namespace, "1", OutputType.RAW) + + write_str_to_file(fragment_file, fragment_text) + + # Sign and push fragment + cose_proxy = CoseSignToolProxy() + iss = cose_proxy.create_issuer(self.chain) + cose_proxy.cose_sign(fragment_file, self.key, self.chain, feed, iss, "ES384", fragment_file + ".cose") + push_fragment_to_registry(feed, fragment_file + ".cose") + + # Create import JSON that references the same fragment twice + import_json = f""" +{{ + "version": "1.0", + "fragments": [ + {{ + "feed": "{feed}", + "issuer": "{iss}", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + }}, + {{ + "feed": "{feed}", + "issuer": "{iss}", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + }} + ], + "containers": [] +}} + """ + + write_str_to_file(import_file, import_json) + + # This should either deduplicate gracefully or handle duplicate imports appropriately + with load_policy_from_json(import_json) as policy: + policy.populate_policy_content_for_all_images() + output = policy.generate_fragment("duplicate_imports", "1", OutputType.RAW) + self.assertIsNotNone(output) + + except Exception as e: + raise e + finally: + force_delete_silently([container_file, fragment_file, fragment_file + ".cose", import_file]) + + def test_two_imports_same_namespace_different_feeds(self): + """Test two imports that share namespace but have different feeds.""" + # Create two fragments with the same namespace but different feeds + container_json = """ +{ + "version": "1.0", + "containers": [ + { + "name": "test-container", + "properties": { + "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", + "execProcesses": [ + { + "command": ["echo", "Hello World"] + } + ], + "environmentVariables": [ + { + "name": "PATH", + "value": "/usr/local/bin" + } + ] + } + } + ] +} + """ + + feed1 = f"{self.registry}/feed1:v1" + feed2 = f"{self.registry}/feed2:v1" + same_namespace = "conflicting_namespace" + + fragment1_file = "fragment1.rego" + fragment2_file = "fragment2.rego" + container_file = "container.json" + import_file = "same_namespace_diff_feeds.json" + + try: + write_str_to_file(container_file, container_json) + + # Create first fragment with specific namespace + with load_policy_from_json(container_json) as policy: + policy.populate_policy_content_for_all_images() + fragment1_text = policy.generate_fragment(same_namespace, "1", OutputType.RAW) + + write_str_to_file(fragment1_file, fragment1_text) + + # Sign and push first fragment + cose_proxy = CoseSignToolProxy() + iss = cose_proxy.create_issuer(self.chain) + cose_proxy.cose_sign(fragment1_file, self.key, self.chain, feed1, iss, "ES384", fragment1_file + ".cose") + push_fragment_to_registry(feed1, fragment1_file + ".cose") + + # Create second fragment with same namespace + fragment2_text = policy.generate_fragment(same_namespace, "1", OutputType.RAW) + write_str_to_file(fragment2_file, fragment2_text) + + # Sign and push second fragment + cose_proxy.cose_sign(fragment2_file, self.key, self.chain, feed2, iss, "ES384", fragment2_file + ".cose") + push_fragment_to_registry(feed2, fragment2_file + ".cose") + + # Create import JSON that references both fragments + import_json = f""" +{{ + "version": "1.0", + "fragments": [ + {{ + "feed": "{feed1}", + "issuer": "{iss}", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + }}, + {{ + "feed": "{feed2}", + "issuer": "{iss}", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + }} + ], + "containers": [] +}} + """ + + write_str_to_file(import_file, import_json) + + # This should fail due to namespace conflict when fragments are evaluated + with self.assertRaises((CLIError, SystemExit, ValueError)): + acipolicygen_confcom( + import_file, + None, + None, + None, + None, + None, + None, + None, + None, + include_fragments=True, + ) + + except Exception as e: + raise e + finally: + force_delete_silently([container_file, fragment1_file, fragment1_file + ".cose", fragment2_file, fragment2_file + ".cose", import_file]) + + def test_mixed_case_feed_namespace_strings(self): + """Test handling of mixed case feed and namespace strings.""" + mixed_case_json = """ +{ + "version": "1.0", + "fragments": [ + { + "feed": "localhost:5000/MixedCase_Feed:V1", + "issuer": "Test_Issuer", + "minimum_svn": "1", + "includes": ["containers", "fragments"] + } + ], + "containers": [] +} + """ + + filename = "mixed_case.json" + try: + write_str_to_file(filename, mixed_case_json) + + with load_policy_from_json(mixed_case_json) as policy: + policy.populate_policy_content_for_all_images() + output = policy.generate_fragment("MixedCase_NameSpace", "1", OutputType.RAW) + self.assertIsNotNone(output) + + # Verify case is preserved + self.assertIn("MixedCase_Feed", output) + self.assertIn("MixedCase_NameSpace", output) + + except Exception as e: + raise e + finally: + force_delete_silently(filename) + + +class InitialFragmentErrors(ScenarioTest): + def test_invalid_input(self): + with self.assertRaises(CLIError) as wrapped_exit: + self.cmd("az confcom acifragmentgen --image mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 -i fakepath/parameters.json --namespace fake_namespace --svn 1") + self.assertEqual(wrapped_exit.exception.args[0], "Must provide either an image name or an input file to generate a fragment") + + with self.assertRaises(CLIError) as wrapped_exit: + self.cmd("az confcom acifragmentgen --generate-import --minimum-svn 1") + self.assertEqual(wrapped_exit.exception.args[0], "Must provide either a fragment path or " + + "an image name to generate an import statement") + + with self.assertRaises(CLIError) as wrapped_exit: + self.cmd("az confcom acifragmentgen --image mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 -k fakepath/key.pem --namespace fake_namespace --svn 1") + self.assertEqual(wrapped_exit.exception.args[0], "Must provide both --key and --chain to sign a fragment") + + with self.assertRaises(CLIError) as wrapped_exit: + self.cmd("az confcom acifragmentgen --fragment-path ./fragment.json --image mcr.microsoft.com/aci/msi-atlas-adapter:master_20201210.1 --namespace fake_namespace --svn 1 --minimum-svn 1") + self.assertEqual(wrapped_exit.exception.args[0], "Must provide --generate-import to specify a fragment path") + + with self.assertRaises(CLIError) as wrapped_exit: + self.cmd("az confcom acifragmentgen --input ./input.json --namespace example --svn -1") + self.assertEqual(wrapped_exit.exception.args[0], "--svn must be an integer") + + with self.assertRaises(CLIError) as wrapped_exit: + self.cmd("az confcom acifragmentgen --input ./input.json --namespace policy --svn 1") + self.assertEqual(wrapped_exit.exception.args[0], "Namespace 'policy' is reserved") + + with self.assertRaises(CLIError) as wrapped_exit: + self.cmd("az confcom acifragmentgen --algo fake_algo --key ./key.pem --chain ./cert-chain.pem --namespace example --svn 1 -i ./input.json") + self.assertEqual(wrapped_exit.exception.args[0], f"Algorithm 'fake_algo' is not supported. Supported algorithms are {config.SUPPORTED_ALGOS}") + + def test_reserved_namespace_validation(self): + """Test additional reserved namespace validation scenarios.""" + + for namespace in config.RESERVED_FRAGMENT_NAMES: + filename = f"reserved_test_{namespace.lower()}.json" + try: + write_str_to_file(filename, """{"version": "1.0", "containers": [{"properties": {"image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9"}, "name": "hello"}]}""") + + with self.assertRaises(CLIError) as exc_info: + self.cmd(f"confcom acifragmentgen -i {filename} --namespace {namespace} --svn 1") + + self.assertIn("reserved", exc_info.exception.args[0].lower()) + + finally: + force_delete_silently(filename) + + def test_bad_svn_validation(self): + """Test various invalid SVN values.""" + invalid_svns = ["-1", "abc", "1.5"] + + for svn in invalid_svns: + filename = f"bad_svn_test_{hash(svn) % 1000}.json" + try: + write_str_to_file(filename, + """{"version": "1.0", "containers": [{"properties": {"image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9"}, "name": "hello"}]}""" + ) + + with self.assertRaises(CLIError): + self.cmd(f"confcom acifragmentgen -i {filename} --namespace test --svn {svn}") + + finally: + force_delete_silently(filename) \ No newline at end of file diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_kata.py b/src/confcom/azext_confcom/tests/latest/test_confcom_kata.py index cfd3bf4d849..1574a203f64 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_kata.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_kata.py @@ -5,8 +5,6 @@ import os import unittest -import unittest.mock as patch -from io import StringIO import platform from azext_confcom.custom import katapolicygen_confcom 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 7616d40705d..9e648c62a02 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_scenario.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_scenario.py @@ -3,18 +3,14 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import json import os import unittest -import json - -from azext_confcom.security_policy import ( - UserContainerImage, - OutputType, - load_policy_from_json, -) import azext_confcom.config as config -from azext_confcom.template_util import case_insensitive_dict_get, DockerClient +from azext_confcom.security_policy import (OutputType, UserContainerImage, + load_policy_from_json) +from azext_confcom.template_util import DockerClient, case_insensitive_dict_get TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) @@ -586,9 +582,9 @@ def test_image_layers_python(self): aci_policy.populate_policy_content_for_all_images() layers = aci_policy.get_images()[0]._layers expected_layers = [ - "4b17d51a118cfa6405698048bbb9f258f70c44235cf54dab8977e689d4422c1d", - "374b10f7af01a18c4408738ec38b302f4766ab62c033208c4b86eb7434ed8217", - "c9d8b0df7e0ab9ff83672dd67f154f28fdee0ae0b62c82a3451a44c8e2e29838" + "335710fca9480be919670dc57ef086019417ca61b4ab6e414ec6564dbf44aba8", + "dd0003aa8186970a6d1911288e0285c8b0f2b2f5624d7478d8fb1130344e3341", + "4a31b681abd27fd7a3a501e8a3e6f3b3d39767311b7f1b7dc17dd58aec6137b8" ] 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 0dc99e631de..ee58400974b 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py @@ -3,25 +3,21 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import os -import unittest -import deepdiff import json +import os import shutil import tempfile +import unittest 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, -) import azext_confcom.config as config +import deepdiff +from azext_confcom.errors import AccContainerError +from azext_confcom.os_util import delete_silently +from azext_confcom.rootfs_proxy import SecurityPolicyProxy +from azext_confcom.security_policy import (OutputType, + load_policy_from_arm_template_str) 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: @@ -34,11 +30,6 @@ def create_tar_file(image_path: str) -> None: f.close() -def remove_tar_file(image_path: str) -> None: - if os.path.isfile(image_path): - os.remove(image_path) - - class PolicyGeneratingArmParametersCleanRoomOCITarFile(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -183,16 +174,14 @@ def test_oci_tar_file(self): try: with tempfile.TemporaryDirectory() as folder: filename = os.path.join(folder, "oci.tar") + filename2 = os.path.join(self.path, "oci2.tar") - tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": os.path.join(self.path, "oci2.tar")} + tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename2} create_tar_file(filename) - with TarFile(f"{folder}/oci.tar", "r") as tar: + with TarFile(filename, "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: + with TarFile.open(filename2, 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) @@ -200,11 +189,7 @@ def test_oci_tar_file(self): 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")) + raise AccContainerError("Could not get image from tar file") from e regular_image_json = json.loads( regular_image.get_serialized_output(output_type=OutputType.RAW, rego_boilerplate=False) @@ -372,10 +357,9 @@ def test_arm_template_with_parameter_file_clean_room_tar(self): tar_mapping=tar_mapping_file ) except Exception as e: - print(e) - raise AccContainerError("Could not get image from tar file") + raise AccContainerError("Could not get image from tar file") from e finally: - remove_tar_file(filename) + delete_silently(filename) regular_image_json = json.loads( regular_image.get_serialized_output(output_type=OutputType.RAW, rego_boilerplate=False) @@ -585,7 +569,7 @@ def test_arm_template_mixed_mode_tar(self): tar_mapping=image_mapping ) - remove_tar_file(filename) + delete_silently(filename) regular_image_json = json.loads( regular_image.get_serialized_output(output_type=OutputType.RAW, rego_boilerplate=False) ) @@ -744,7 +728,7 @@ def test_arm_template_with_parameter_file_clean_room_tar_invalid(self): except: pass finally: - remove_tar_file(filename) + delete_silently(filename) def test_clean_room_fake_tar_invalid(self): custom_arm_json_default_value = """ 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 4cab7fe2b0e..c6e8ad4a23a 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 @@ -427,12 +427,7 @@ def test_virtual_node_policy_fragments(self): 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") + os_util.force_delete_silently([fragment_filename, yaml_filename, import_filename, signed_file_path, f"{rego_filename}.rego"]) def test_configmaps(self): diff --git a/src/confcom/samples/certs/README.md b/src/confcom/samples/certs/README.md index 885e9f79f74..ac58bbcdb80 100644 --- a/src/confcom/samples/certs/README.md +++ b/src/confcom/samples/certs/README.md @@ -29,7 +29,7 @@ The image in `fragment_config.json` must be updated from `` to the i After completion, this will create the following files to be used in the confcom signing process: -- `intermediate/private/ec_p384_private.pem` +- `intermediateCA/private/ec_p384_private.pem` - `intermediateCA/certs/www.contoso.com.chain.cert.pem` Note that for consecutive runs, the script will not completely overwrite the existing key and cert files. It is recommended to either delete the existing files or modify the path to create the new files elsewhere. diff --git a/src/confcom/setup.py b/src/confcom/setup.py index 2be1389e112..3ce7907b25a 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.6" +VERSION = "1.2.7" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 6f26dac81356f81dfbfa5d905188899ae373fdd7 Mon Sep 17 00:00:00 2001 From: sethho Date: Wed, 27 Aug 2025 16:04:57 -0400 Subject: [PATCH 2/5] updating version --- src/confcom/HISTORY.rst | 2 ++ src/confcom/azext_confcom/oras_proxy.py | 7 ++++--- src/confcom/azext_confcom/template_util.py | 17 ++++++++--------- .../tests/latest/test_confcom_fragment.py | 6 ++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst index fea04f2cd05..3006fd18e73 100644 --- a/src/confcom/HISTORY.rst +++ b/src/confcom/HISTORY.rst @@ -7,6 +7,8 @@ Release History ++++++ * bugfix making it so that oras discover function doesn't error when no fragments are found in the remote repository * splitting out documentation into command-specific files and adding info about --input flag +* adding standalone fragment support +* bugfix for oras pulling fragments when offline 1.2.6 ++++++ diff --git a/src/confcom/azext_confcom/oras_proxy.py b/src/confcom/azext_confcom/oras_proxy.py index 26557437ae6..89ea94c036e 100644 --- a/src/confcom/azext_confcom/oras_proxy.py +++ b/src/confcom/azext_confcom/oras_proxy.py @@ -84,8 +84,7 @@ def discover( err_str = item.stderr.decode("utf-8") if "401: Unauthorized" in err_str: logger.warning( - "Error pulling the policy fragment from %s.\n\n" - + "Please log into the registry and try again.\n\n", + "Error pulling the policy fragment from %s.\n\nPlease log into the registry and try again.\n\n", image ) image_exists = False @@ -94,7 +93,7 @@ def discover( logger.warning("No policy fragments found for image %s", image) image_exists = False elif "dial tcp: lookup" in err_str: - logger.warning(f"Could not access registry for {image}") + logger.warning("Could not access registry for %s", image) image_exists = False else: eprint(f"Error retrieving fragments from remote repo: {err_str}", exit_code=item.returncode) @@ -232,6 +231,7 @@ def check_oras_cli(): eprint(text) +# used for image-attached fragments def attach_fragment_to_image(image_name: str, filename: str): if ":" not in image_name: image_name += ":latest" @@ -276,6 +276,7 @@ def generate_imports_from_image_name(image_name: str, minimum_svn: str) -> List[ return import_list +# used for standalone fragments def push_fragment_to_registry(feed_name: str, filename: str) -> None: # push the fragment to the registry arg_list = [ diff --git a/src/confcom/azext_confcom/template_util.py b/src/confcom/azext_confcom/template_util.py index 87ded36a257..c968695d5f7 100644 --- a/src/confcom/azext_confcom/template_util.py +++ b/src/confcom/azext_confcom/template_util.py @@ -4,21 +4,19 @@ # -------------------------------------------------------------------------------------------- import base64 -import re -import json import copy +import json +import re import tarfile -from typing import Any, Tuple, Dict, List from hashlib import sha256 +from typing import Any, Dict, List, Tuple + import deepdiff -import yaml import docker +import yaml +from azext_confcom import config, os_util +from azext_confcom.errors import eprint 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__) @@ -29,6 +27,7 @@ SVN_PATTERN = r'svn\s*:=\s*"(\d+)"' NAMESPACE_PATTERN = r'package\s+([a-zA-Z_][a-zA-Z0-9_]*)' + class DockerClient: _client = None 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 c1588c3dadd..5b0b6401c71 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -1160,14 +1160,12 @@ def setUpClass(cls): container = existing_containers[0] if container.status != 'running': container.start() - except docker.errors.DockerException as e: - raise Exception(f"Docker is not available: {e}") - - # Replace subprocess.run(f"docker pull {cls.zot_image}", shell=True) except docker.errors.ImageNotFound: raise Exception(f"Could not pull image {cls.zot_image}") except docker.errors.APIError as e: raise Exception(f"Error pulling image {cls.zot_image}: {e}") + except docker.errors.DockerException as e: + raise Exception(f"Docker is not available: {e}") cls.key_dir_parent = os.path.join(SAMPLES_DIR, 'certs') cls.key = os.path.join(cls.key_dir_parent, 'intermediateCA', 'private', 'ec_p384_private.pem') From 2715468096f922712a13c50c685acb28dacfc420 Mon Sep 17 00:00:00 2001 From: sethho Date: Thu, 28 Aug 2025 09:38:21 -0400 Subject: [PATCH 3/5] adding print for binary version --- src/confcom/azext_confcom/cose_proxy.py | 20 ++++++++++---------- src/confcom/azext_confcom/kata_proxy.py | 8 +++++--- src/confcom/azext_confcom/rootfs_proxy.py | 14 ++++++++------ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/confcom/azext_confcom/cose_proxy.py b/src/confcom/azext_confcom/cose_proxy.py index 29779c46adf..2073eafb0ce 100644 --- a/src/confcom/azext_confcom/cose_proxy.py +++ b/src/confcom/azext_confcom/cose_proxy.py @@ -3,24 +3,22 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import subprocess import os -import stat import platform +import stat +import subprocess 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, - REGO_FRAGMENT_START, - POLICY_FIELD_CONTAINERS, + ACI_FIELD_CONTAINERS_REGO_FRAGMENTS_INCLUDES, POLICY_FIELD_CONTAINERS, POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS, - POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_ISSUER, POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_FEED, + POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_ISSUER, POLICY_FIELD_CONTAINERS_ELEMENTS_REGO_FRAGMENTS_MINIMUM_SVN, - ACI_FIELD_CONTAINERS_REGO_FRAGMENTS_INCLUDES, -) + REGO_CONTAINER_START, REGO_FRAGMENT_START) +from azext_confcom.errors import eprint +from knack.log import get_logger logger = get_logger(__name__) host_os = platform.system() @@ -57,6 +55,8 @@ def download_binaries(): needed_asset_info = [asset for asset in release["assets"] if asset["name"] in needed_assets] if len(needed_asset_info) == len(needed_assets): for asset in needed_asset_info: + # say which version we're downloading + print(f"Downloading integrity-vhd version {release['tag_name']}") # get the download url for the dmverity-vhd file exe_url = asset["browser_download_url"] # download the file diff --git a/src/confcom/azext_confcom/kata_proxy.py b/src/confcom/azext_confcom/kata_proxy.py index 01487021e68..51daaaecdd1 100644 --- a/src/confcom/azext_confcom/kata_proxy.py +++ b/src/confcom/azext_confcom/kata_proxy.py @@ -3,16 +3,16 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import subprocess import os +import platform import stat +import subprocess import sys -import platform + import requests from azext_confcom.config import DATA_FOLDER from azext_confcom.errors import eprint - host_os = platform.system() machine = platform.machine() @@ -50,6 +50,8 @@ def download_binaries(): for asset in release["assets"]: # download the file if it contains genpolicy if asset["name"] in needed_assets: + # say which version we're downloading + print(f"Downloading genpolicy version {release['tag_name']}") save_name = "" if ".exe" in asset["name"]: save_name = "genpolicy-windows.exe" diff --git a/src/confcom/azext_confcom/rootfs_proxy.py b/src/confcom/azext_confcom/rootfs_proxy.py index d9d7ba35f89..4acccf56b1f 100644 --- a/src/confcom/azext_confcom/rootfs_proxy.py +++ b/src/confcom/azext_confcom/rootfs_proxy.py @@ -4,16 +4,16 @@ # -------------------------------------------------------------------------------------------- -import subprocess -from typing import List import os -import sys -import stat import platform +import stat +import subprocess +import sys +from typing import List + import requests -from knack.log import get_logger from azext_confcom.errors import eprint - +from knack.log import get_logger host_os = platform.system() machine = platform.machine() @@ -42,6 +42,8 @@ def download_binaries(): needed_asset_info = [asset for asset in release["assets"] if asset["name"] in needed_assets] if len(needed_asset_info) == len(needed_assets): for asset in needed_asset_info: + # say which version we're downloading + print(f"Downloading integrity-vhd version {release['tag_name']}") # get the download url for the dmverity-vhd file exe_url = asset["browser_download_url"] # download the file From 41e298e3b4e3aec84f5f8dd97337031c1462678d Mon Sep 17 00:00:00 2001 From: SethHollandsworth Date: Thu, 28 Aug 2025 16:45:30 -0400 Subject: [PATCH 4/5] commenting out some tests due to docker incompatibility --- src/confcom/azext_confcom/oras_proxy.py | 2 +- .../tests/latest/test_confcom_fragment.py | 2251 +++++++++-------- 2 files changed, 1128 insertions(+), 1125 deletions(-) diff --git a/src/confcom/azext_confcom/oras_proxy.py b/src/confcom/azext_confcom/oras_proxy.py index 89ea94c036e..a53f69127c0 100644 --- a/src/confcom/azext_confcom/oras_proxy.py +++ b/src/confcom/azext_confcom/oras_proxy.py @@ -82,7 +82,7 @@ def discover( # get the exit code from the subprocess else: err_str = item.stderr.decode("utf-8") - if "401: Unauthorized" in err_str: + if "unauthorized" in err_str.lower(): logger.warning( "Error pulling the policy fragment from %s.\n\nPlease log into the registry and try again.\n\n", image 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 5b0b6401c71..2725ede31c0 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -34,6 +34,7 @@ TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) SAMPLES_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", '..', '..', '..', 'samples')) + class FragmentMountEnforcement(unittest.TestCase): custom_json = """ { @@ -972,1132 +973,1134 @@ def test_fragment_vn2_workload_identity_mounts(self): default_mounts = [i.get('mountPath') for i in config.DEFAULT_MOUNTS_WORKLOAD_IDENTITY_VIRTUAL_NODE] for default_mount in default_mounts: self.assertIn(default_mount, mount_destinations) -class FragmentRegistryInteractions(ScenarioTest): - custom_json = """ -{ - "version": "1.0", - "fragments": [ - ], - "containers": [ - { - "name": "my-image2", - "properties": { - "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", - "execProcesses": [ - { - "command": [ - "echo", - "Hello World" - ] - } - ], - "volumeMounts": [ - { - "name": "azurefile", - "mountPath": "/mount/azurefile", - "mountType": "azureFile", - "readOnly": true - } - ], - "environmentVariables": [ - { - "name": "PATH", - "value": "/customized/path/value" - }, - { - "name": "TEST_REGEXP_ENV", - "value": "test_regexp_env(.*)", - "regex": true - } - ] - } - } - ] -} - """ - - - custom_json2 = """ -{ - "version": "1.0", - "fragments": [ - ], - "containers": [ - { - "name": "my-image", - "properties": { - "image": "mcr.microsoft.com/azurelinux/busybox:1.36", - "execProcesses": [ - { - "command": [ - "sleep", - "infinity" - ] - } - ], - "environmentVariables": [ - { - "name": "PATH", - "value": "/another/customized/path/value" - }, - { - "name": "TEST_REGEXP_ENV2", - "value": "test_regexp_env2(.*)", - "regex": true - } - ] - } - }, - { - "name": "my-image2", - "properties": { - "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", - "execProcesses": [ - { - "command": [ - "echo", - "Hello World" - ] - } - ], - "volumeMounts": [ - { - "name": "azurefile", - "mountPath": "/mount/azurefile", - "mountType": "azureFile", - "readOnly": true - } - ], - "environmentVariables": [ - { - "name": "PATH", - "value": "/customized/path/value" - }, - { - "name": "TEST_REGEXP_ENV", - "value": "test_regexp_env(.*)", - "regex": true - } - ] - } - } - ] -} -""" - - custom_json3 = """ - { - "version": "1.0", - "fragments": [ - ], - "containers": [ - { - "name": "my-image", - "properties": { - "image": "localhost:5000/helloworld:2.9", - "execProcesses": [ - { - "command": [ - "echo", - "Hello World" - ] - } - ], - "volumeMounts": [ - { - "name": "azurefile", - "mountPath": "/mount/azurefile", - "mountType": "azureFile", - "readOnly": true - } - ], - "environmentVariables": [ - { - "name": "PATH", - "value": "/customized/path/value" - }, - { - "name": "TEST_REGEXP_ENV", - "value": "test_regexp_env(.*)", - "regex": true - } - ] - } - } - ] - } - """ - - @classmethod - def setUpClass(cls): - # start the zot registry - cls.zot_image = "ghcr.io/project-zot/zot-linux-amd64:latest" - cls.registry = "localhost:5000" - registry_name = "myregistry" - - # Initialize Docker client - try: - with DockerClient() as client: - client.images.pull(cls.zot_image) - - # Replace output = subprocess.run("docker ps -a", capture_output=True, shell=True) - # Check if container already exists - existing_containers = client.containers.list(all=True, filters={"name": registry_name}) - - # Replace subprocess.run(f"docker run --name {registry_name} -d -p 5000:5000 {cls.zot_image}", shell=True) - if not existing_containers: - try: - client.containers.run( - cls.zot_image, - name=registry_name, - ports={'5000/tcp': 5000}, - detach=True - ) - except docker.errors.APIError as e: - raise Exception(f"Error starting registry container: {e}") - else: - # Start the container if it exists but is not running - container = existing_containers[0] - if container.status != 'running': - container.start() - except docker.errors.ImageNotFound: - raise Exception(f"Could not pull image {cls.zot_image}") - except docker.errors.APIError as e: - raise Exception(f"Error pulling image {cls.zot_image}: {e}") - except docker.errors.DockerException as e: - raise Exception(f"Docker is not available: {e}") - - cls.key_dir_parent = os.path.join(SAMPLES_DIR, '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_json(cls.custom_json) as aci_policy: - aci_policy.populate_policy_content_for_all_images() - cls.aci_policy = aci_policy - with load_policy_from_json(cls.custom_json2) as aci_policy2: - aci_policy2.populate_policy_content_for_all_images() - cls.aci_policy2 = aci_policy2 - - # stall while we wait for the registry to start running - result = requests.get(f"http://{cls.registry}/v2/_catalog") - counter = 0 - retry_limit = 10 - while result.status_code != 200: - time.sleep(1) - result = requests.get(f"http://{cls.registry}/v2/_catalog") - counter += 1 - if counter == retry_limit: - raise Exception("Could not start local registry in time") - - - def test_generate_import_from_remote(self): - filename = "payload5.rego" - feed = f"{self.registry}/test_feed:test_tag" - algo = "ES384" - out_path = filename + ".cose" - - fragment_text = self.aci_policy.generate_fragment("payload4", "1", OutputType.RAW) - temp_filename = "temp.json" - 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) - push_fragment_to_registry(feed, out_path) - - # this should download and create the import statement - acifragmentgen_confcom(None, None, None, None, None, None, None, None, "1", generate_import=True, fragment_path=feed, fragments_json=temp_filename) - import_file = load_json_from_file(temp_filename) - import_statement = import_file.get(config.ACI_FIELD_CONTAINERS_REGO_FRAGMENTS)[0] - - 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, out_path, temp_filename]) - - def test_remote_fragment_references(self): - filename = "payload6.rego" - filename2 = "payload7.rego" - first_fragment = "first_fragment.json" - fragment_json = "fragment_remote.json" - feed = f"{self.registry}/test_feed:v1" - feed2 = f"{self.registry}/test_feed2:v2" - out_path = filename + ".cose" - out_path2 = filename2 + ".cose" - - # fragment_text = self.aci_policy.generate_fragment("payload6", 1, OutputType.RAW) - - try: - write_str_to_file(first_fragment, self.custom_json) - write_str_to_file(fragment_json, self.custom_json2) - acifragmentgen_confcom( - None, first_fragment, None, "payload7", "1", feed, self.key, self.chain, None, output_filename=filename - ) - - # 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 - ) - - push_fragment_to_registry(feed, out_path) - - acifragmentgen_confcom( - None, fragment_json, None, "payload7", "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(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.9" in rego_str) - except Exception as e: - raise e - finally: - delete_silently([filename, out_path, filename2, out_path2, fragment_json, first_fragment]) - - def test_incorrect_minimum_svn(self): - filename = "payload8.rego" - filename2 = "payload9.rego" - fragment_json = "fragment.json" - feed = f"{self.registry}/test_feed:v3" - feed2 = f"{self.registry}/test_feed2:v4" - algo = "ES384" - out_path = filename + ".cose" - out_path2 = filename2 + ".cose" - - fragment_text = self.aci_policy.generate_fragment("payload8", "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="2", fragments_json=fragment_json, fragment_path=out_path - ) - # put the "path" field into the import statement - push_fragment_to_registry(feed, out_path) - acifragmentgen_confcom( - None, fragment_json, None, "payload9", "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 is in the second fragment because the svn prevents usage - self.assertTrue("mcr.microsoft.com/acc/samples/aci/helloworld:2.9" in rego_str) - except Exception as e: - raise e - finally: - delete_silently([filename, out_path, filename2, out_path2, fragment_json]) - - def test_image_attached_fragment_coverage(self): - # Initialize Docker client - try: - with DockerClient() as client: - # Replace subprocess.run(f"docker tag mcr.microsoft.com/acc/samples/aci/helloworld:2.9 {self.registry}/helloworld:2.9", shell=True) - try: - source_image = client.images.get("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") - source_image.tag(f"{self.registry}/helloworld:2.9") - except docker.errors.ImageNotFound: - # Try to pull the image first - try: - client.images.pull("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") - source_image = client.images.get("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") - source_image.tag(f"{self.registry}/helloworld:2.9") - except docker.errors.APIError as e: - raise Exception(f"Could not pull or tag image: {e}") - except docker.errors.APIError as e: - raise Exception(f"Error tagging image: {e}") - - # Replace subprocess.run(f"docker push {self.registry}/helloworld:2.9", timeout=30, shell=True) - try: - # Note: Docker SDK push returns a generator of status updates - push_logs = client.images.push(f"{self.registry}/helloworld:2.9", stream=True, decode=True) - # Consume the generator to ensure push completes - for log in push_logs: - if 'error' in log: - raise Exception(f"Push failed: {log['error']}") - except docker.errors.APIError as e: - raise Exception(f"Error pushing image: {e}") - except docker.errors.DockerException as e: - raise Exception(f"Docker is not available: {e}") - - filename = "container_image_attached.json" - rego_filename = "temp_namespace" - try: - write_str_to_file(filename, self.custom_json3) - acifragmentgen_confcom( - None, - filename, - None, - rego_filename, - "1", - "temp_feed", - self.key, - self.chain, - "1", - f"{self.registry}/helloworld:2.9", - upload_fragment=True, - ) - - - # this will insert the import statement into the original container.json - acifragmentgen_confcom( - f"{self.registry}/helloworld:2.9", None, None, None, None, None, None, None, generate_import=True, minimum_svn="1", fragments_json=filename - ) - - # try to generate the policy again to make sure there are no containers in the resulting rego - with self.assertRaises(SystemExit) as exc_info: - acifragmentgen_confcom( - None, - filename, - None, - "temp_namespace2", - "1", - "temp_feed2", - None, - None, - "1", - f"{self.registry}/helloworld:2.9", - ) - self.assertEqual(exc_info.exception.code, 1) - - except Exception as e: - raise e - finally: - force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}.rego.cose"]) - - def test_incorrect_pull_location(self): - with self.assertRaises(SystemExit) as exc_info: - _ = pull(f"{self.registry}/fake_artifact") - self.assertEqual(exc_info.exception.code, 1) - - def test_reserved_namespace(self): - filename = "container_image_attached2.json" - rego_filename = "policy" - with self.assertRaises(CLIError) as exc_info: - try: - write_str_to_file(filename, self.custom_json) - self.cmd(f"confcom acifragmentgen -i {filename} --namespace policy --svn 1") - except Exception as e: - raise e - finally: - force_delete_silently([filename, f"{rego_filename}.rego"]) - - def test_invalid_svn(self): - filename = "container_image_attached3.json" - rego_filename = "myfile" - with self.assertRaises(CLIError) as exc_info: - try: - write_str_to_file(filename, self.custom_json) - self.cmd(f"confcom acifragmentgen -i {filename} --namespace policy --svn 0") - except Exception as e: - raise e - finally: - force_delete_silently([filename, f"{rego_filename}.rego"]) - -class ExtendedFragmentTests(ScenarioTest): - """Extended test cases for fragment operations following the requested scenarios.""" - - custom_json_multi_container = """ -{ - "version": "1.0", - "fragments": [], - "containers": [ - { - "name": "first-container", - "properties": { - "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", - "execProcesses": [ - { - "command": ["echo", "First Container"] - } - ], - "environmentVariables": [ - { - "name": "CONTAINER_TYPE", - "value": "first" - } - ] - } - }, - { - "name": "second-container", - "properties": { - "image": "mcr.microsoft.com/azurelinux/busybox:1.36", - "execProcesses": [ - { - "command": ["echo", "Second Container"] - } - ], - "environmentVariables": [ - { - "name": "CONTAINER_TYPE", - "value": "second" - } - ] - } - } - ] -} - """ - - custom_json_stdio_disabled = """ -{ - "version": "1.0", - "fragments": [], - "containers": [ - { - "name": "stdio-disabled-container", - "properties": { - "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", - "execProcesses": [ - { - "command": ["echo", "No stdio access"] - } - ], - "environmentVariables": [ - { - "name": "STDIO_DISABLED", - "value": "true" - } - ] - } - } - ] -} - """ - - @classmethod - def setUpClass(cls): - cls.registry = "localhost:5000" - cls.key_dir_parent = os.path.join(SAMPLES_DIR, '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') - - def test_upload_signed_fragment_to_registry(self): - """Test uploading a signed fragment to the registry.""" - filename = "signed_fragment.rego" - feed = f"{self.registry}/signed_fragment:v1" - algo = "ES384" - out_path = filename + ".cose" - - try: - # Create a simple fragment policy - with load_policy_from_json(self.custom_json_multi_container) as policy: - policy.populate_policy_content_for_all_images() - fragment_text = policy.generate_fragment("signed_fragment", "1", OutputType.RAW) - - write_str_to_file(filename, fragment_text) - - # Sign and upload to registry - cose_proxy = CoseSignToolProxy() - iss = cose_proxy.create_issuer(self.chain) - cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path) - - # Upload to registry - push_fragment_to_registry(feed, out_path) - - # Verify we can pull it back - pulled_fragment = pull(feed) - self.assertIsNotNone(pulled_fragment) - - except Exception as e: - raise e - finally: - force_delete_silently([filename, out_path]) - - def test_attach_fragment_to_different_image(self): - """Test attaching a fragment to a different image than the one it was created for.""" - filename = "different_image_fragment.json" - rego_filename = "different_image_rego" - - try: - write_str_to_file(filename, self.custom_json_multi_container) - - # Create fragment for first image but try to attach to second - acifragmentgen_confcom( - None, - filename, - None, - rego_filename, - "1", - "test_feed_different", - self.key, - self.chain, - "1", - "mcr.microsoft.com/azurelinux/busybox:1.36", # Different from the one in JSON - upload_fragment=False, - ) - - # Verify the fragment was created - self.assertTrue(os.path.exists(f"{rego_filename}.rego")) - - except Exception as e: - raise e - finally: - force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}.rego.cose"]) - - def test_remote_pull_failure_path(self): - """Test failure path when trying to pull from non-existent registry location.""" - with self.assertRaises(SystemExit) as exc_info: - _ = pull(f"{self.registry}/nonexistent_fragment:v1") - self.assertEqual(exc_info.exception.code, 1) - - def test_mixed_fragments_and_standalone_fragments_import(self): - """Test import JSON with both 'fragments' and 'standaloneFragments' sections.""" - mixed_json = """ -{ - "version": "1.0", - "fragments": [ - { - "feed": "localhost:5000/fragment1:v1", - "issuer": "test_issuer", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - } - ], - "standaloneFragments": [ - { - "feed": "localhost:5000/standalone1:v1", - "issuer": "test_issuer", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - } - ], - "containers": [] -} - """ - - filename = "mixed_fragments.json" - try: - write_str_to_file(filename, mixed_json) - - # This should handle both fragment types - with load_policy_from_json(mixed_json) as policy: - policy.populate_policy_content_for_all_images() - output = policy.generate_fragment("mixed_fragments", "1", OutputType.RAW) - self.assertIsNotNone(output) - - except Exception as e: - raise e - finally: - force_delete_silently(filename) - - def test_import_json_as_array(self): - """Test import JSON that is just an array instead of object.""" - array_json = """ -[ - { - "feed": "localhost:5000/array_fragment:v1", - "issuer": "test_issuer", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - } -] - """ - - filename = "array_import.json" - try: - write_str_to_file(filename, array_json) - - # This should fail or handle gracefully - with self.assertRaises((ValueError, CLIError, SystemExit)): - with load_policy_from_json(array_json) as policy: - policy.populate_policy_content_for_all_images() - - except Exception as e: - raise e - finally: - force_delete_silently(filename) - - def test_disable_stdio_access(self): - """Test fragment generation with stdio access disabled.""" - filename = "stdio_disabled.json" - rego_filename = "stdio_disabled_rego" - - try: - write_str_to_file(filename, self.custom_json_stdio_disabled) - - acifragmentgen_confcom( - None, - filename, - None, - rego_filename, - "1", - "stdio_test_feed", - None, - None, - None, - disable_stdio=True, - ) - - # Verify stdio access is disabled in the generated policy - rego_content = load_str_from_file(f"{rego_filename}.rego") - containers, _ = decompose_confidential_properties(str_to_base64(rego_content)) - - # Check that stdio access is disabled - self.assertFalse(containers[0].get("allow_stdio_access", True)) - - except Exception as e: - raise e - finally: - force_delete_silently([filename, f"{rego_filename}.rego"]) - - def test_tar_input_processing(self): - """Test processing tar and tar-mapping inputs.""" - tar_filename = "test_input.tar" - mapping_filename = "test_mapping.json" - - try: - # Create a simple tar mapping - tar_mapping = { - "tar_file": tar_filename, - "containers": [ - { - "name": "tar-container", - "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9" - } - ] - } - - write_str_to_file(mapping_filename, json.dumps(tar_mapping)) - - # Create empty tar file for testing - with open(tar_filename, 'wb') as f: - f.write(b'') - - # This should handle tar input gracefully or fail with appropriate error - with self.assertRaises((FileNotFoundError, CLIError, SystemExit)): - acifragmentgen_confcom( - None, - mapping_filename, - tar_filename, - "tar_test_rego", - "1", - "tar_test_feed", - None, - None, - None - ) - - except Exception as e: - raise e - finally: - force_delete_silently([tar_filename, mapping_filename]) - - def test_fragment_target_image_consistency(self): - """Test that fragment always lands on the intended image with and without --image-target.""" - filename = "target_consistency.json" - rego_filename = "target_consistency_rego" - target_image = "mcr.microsoft.com/acc/samples/aci/helloworld:2.9" - - try: - write_str_to_file(filename, self.custom_json_multi_container) - - # Test with explicit image target - acifragmentgen_confcom( - None, - filename, - None, - rego_filename, - "1", - "target_test_feed", - None, - None, - "1", - target_image - ) - - # Verify the fragment contains the correct image - rego_content = load_str_from_file(f"{rego_filename}.rego") - self.assertIn(target_image, rego_content) - - # Test without explicit image target (should use from JSON) - acifragmentgen_confcom( - None, - filename, - None, - f"{rego_filename}_no_target", - "1", - "target_test_feed_2", - None, - None, - None - ) - - # Should contain images from JSON - rego_content_2 = load_str_from_file(f"{rego_filename}_no_target.rego") - self.assertIn("mcr.microsoft.com/acc/samples/aci/helloworld:2.9", rego_content_2) - - except Exception as e: - raise e - finally: - force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}_no_target.rego"]) - - def test_two_imports_same_feed_different_namespaces(self): - """Test two imports that reference the same feed but expect different namespaces.""" - # Note: This is actually testing an edge case where the same feed is referenced - # but the system might expect different namespace handling - container_json = """ -{ - "version": "1.0", - "containers": [ - { - "name": "shared-feed-container", - "properties": { - "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", - "execProcesses": [ - { - "command": ["echo", "Shared Feed Test"] - } - ], - "environmentVariables": [ - { - "name": "SHARED_FEED_TEST", - "value": "true" - } - ] - } - } - ] -} - """ - - feed = f"{self.registry}/shared_feed:v1" - namespace = "actual_namespace" - - fragment_file = "shared_feed_fragment.rego" - container_file = "shared_feed_container.json" - import_file = "same_feed_diff_namespace.json" - - try: - write_str_to_file(container_file, container_json) - - # Create and push fragment with a specific namespace - with load_policy_from_json(container_json) as policy: - policy.populate_policy_content_for_all_images() - fragment_text = policy.generate_fragment(namespace, "1", OutputType.RAW) - - write_str_to_file(fragment_file, fragment_text) - - # Sign and push fragment - cose_proxy = CoseSignToolProxy() - iss = cose_proxy.create_issuer(self.chain) - cose_proxy.cose_sign(fragment_file, self.key, self.chain, feed, iss, "ES384", fragment_file + ".cose") - push_fragment_to_registry(feed, fragment_file + ".cose") - - # Create import JSON that references the same feed twice - # The system should handle this gracefully since it's the same fragment - import_json = f""" -{{ - "version": "1.0", - "fragments": [ - {{ - "feed": "{feed}", - "issuer": "{iss}", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - }}, - {{ - "feed": "{feed}", - "issuer": "{iss}", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - }} - ], - "containers": [] -}} - """ - - write_str_to_file(import_file, import_json) - - # This should work since it's the same feed/fragment being referenced twice - with load_policy_from_json(import_json) as policy: - policy.populate_policy_content_for_all_images() - output = policy.generate_fragment("same_feed_diff_namespace", "1", OutputType.RAW) - self.assertIsNotNone(output) - - except Exception as e: - raise e - finally: - force_delete_silently([container_file, fragment_file, fragment_file + ".cose", import_file]) - - def test_two_imports_same_feed_and_namespace(self): - """Test two imports that share both feed and namespace.""" - # Create a single fragment and try to import it twice - container_json = """ -{ - "version": "1.0", - "containers": [ - { - "name": "duplicate-test-container", - "properties": { - "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", - "execProcesses": [ - { - "command": ["echo", "Duplicate Test"] - } - ], - "environmentVariables": [ - { - "name": "DUPLICATE_TEST", - "value": "true" - } - ] - } - } - ] -} - """ - - feed = f"{self.registry}/duplicate_feed:v1" - namespace = "duplicate_namespace" - - fragment_file = "duplicate_fragment.rego" - container_file = "duplicate_container.json" - import_file = "duplicate_imports.json" - - try: - write_str_to_file(container_file, container_json) - - # Create and push fragment - with load_policy_from_json(container_json) as policy: - policy.populate_policy_content_for_all_images() - fragment_text = policy.generate_fragment(namespace, "1", OutputType.RAW) - - write_str_to_file(fragment_file, fragment_text) - - # Sign and push fragment - cose_proxy = CoseSignToolProxy() - iss = cose_proxy.create_issuer(self.chain) - cose_proxy.cose_sign(fragment_file, self.key, self.chain, feed, iss, "ES384", fragment_file + ".cose") - push_fragment_to_registry(feed, fragment_file + ".cose") - - # Create import JSON that references the same fragment twice - import_json = f""" -{{ - "version": "1.0", - "fragments": [ - {{ - "feed": "{feed}", - "issuer": "{iss}", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - }}, - {{ - "feed": "{feed}", - "issuer": "{iss}", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - }} - ], - "containers": [] -}} - """ - - write_str_to_file(import_file, import_json) - - # This should either deduplicate gracefully or handle duplicate imports appropriately - with load_policy_from_json(import_json) as policy: - policy.populate_policy_content_for_all_images() - output = policy.generate_fragment("duplicate_imports", "1", OutputType.RAW) - self.assertIsNotNone(output) - - except Exception as e: - raise e - finally: - force_delete_silently([container_file, fragment_file, fragment_file + ".cose", import_file]) - - def test_two_imports_same_namespace_different_feeds(self): - """Test two imports that share namespace but have different feeds.""" - # Create two fragments with the same namespace but different feeds - container_json = """ -{ - "version": "1.0", - "containers": [ - { - "name": "test-container", - "properties": { - "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", - "execProcesses": [ - { - "command": ["echo", "Hello World"] - } - ], - "environmentVariables": [ - { - "name": "PATH", - "value": "/usr/local/bin" - } - ] - } - } - ] -} - """ - - feed1 = f"{self.registry}/feed1:v1" - feed2 = f"{self.registry}/feed2:v1" - same_namespace = "conflicting_namespace" - - fragment1_file = "fragment1.rego" - fragment2_file = "fragment2.rego" - container_file = "container.json" - import_file = "same_namespace_diff_feeds.json" - - try: - write_str_to_file(container_file, container_json) - - # Create first fragment with specific namespace - with load_policy_from_json(container_json) as policy: - policy.populate_policy_content_for_all_images() - fragment1_text = policy.generate_fragment(same_namespace, "1", OutputType.RAW) - - write_str_to_file(fragment1_file, fragment1_text) - - # Sign and push first fragment - cose_proxy = CoseSignToolProxy() - iss = cose_proxy.create_issuer(self.chain) - cose_proxy.cose_sign(fragment1_file, self.key, self.chain, feed1, iss, "ES384", fragment1_file + ".cose") - push_fragment_to_registry(feed1, fragment1_file + ".cose") - - # Create second fragment with same namespace - fragment2_text = policy.generate_fragment(same_namespace, "1", OutputType.RAW) - write_str_to_file(fragment2_file, fragment2_text) - - # Sign and push second fragment - cose_proxy.cose_sign(fragment2_file, self.key, self.chain, feed2, iss, "ES384", fragment2_file + ".cose") - push_fragment_to_registry(feed2, fragment2_file + ".cose") - - # Create import JSON that references both fragments - import_json = f""" -{{ - "version": "1.0", - "fragments": [ - {{ - "feed": "{feed1}", - "issuer": "{iss}", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - }}, - {{ - "feed": "{feed2}", - "issuer": "{iss}", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - }} - ], - "containers": [] -}} - """ - - write_str_to_file(import_file, import_json) - - # This should fail due to namespace conflict when fragments are evaluated - with self.assertRaises((CLIError, SystemExit, ValueError)): - acipolicygen_confcom( - import_file, - None, - None, - None, - None, - None, - None, - None, - None, - include_fragments=True, - ) - - except Exception as e: - raise e - finally: - force_delete_silently([container_file, fragment1_file, fragment1_file + ".cose", fragment2_file, fragment2_file + ".cose", import_file]) - - def test_mixed_case_feed_namespace_strings(self): - """Test handling of mixed case feed and namespace strings.""" - mixed_case_json = """ -{ - "version": "1.0", - "fragments": [ - { - "feed": "localhost:5000/MixedCase_Feed:V1", - "issuer": "Test_Issuer", - "minimum_svn": "1", - "includes": ["containers", "fragments"] - } - ], - "containers": [] -} - """ - - filename = "mixed_case.json" - try: - write_str_to_file(filename, mixed_case_json) - - with load_policy_from_json(mixed_case_json) as policy: - policy.populate_policy_content_for_all_images() - output = policy.generate_fragment("MixedCase_NameSpace", "1", OutputType.RAW) - self.assertIsNotNone(output) - - # Verify case is preserved - self.assertIn("MixedCase_Feed", output) - self.assertIn("MixedCase_NameSpace", output) - - except Exception as e: - raise e - finally: - force_delete_silently(filename) +# class FragmentRegistryInteractions(ScenarioTest): +# custom_json = """ +# { +# "version": "1.0", +# "fragments": [ +# ], +# "containers": [ +# { +# "name": "my-image2", +# "properties": { +# "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", +# "execProcesses": [ +# { +# "command": [ +# "echo", +# "Hello World" +# ] +# } +# ], +# "volumeMounts": [ +# { +# "name": "azurefile", +# "mountPath": "/mount/azurefile", +# "mountType": "azureFile", +# "readOnly": true +# } +# ], +# "environmentVariables": [ +# { +# "name": "PATH", +# "value": "/customized/path/value" +# }, +# { +# "name": "TEST_REGEXP_ENV", +# "value": "test_regexp_env(.*)", +# "regex": true +# } +# ] +# } +# } +# ] +# } +# """ + + +# custom_json2 = """ +# { +# "version": "1.0", +# "fragments": [ +# ], +# "containers": [ +# { +# "name": "my-image", +# "properties": { +# "image": "mcr.microsoft.com/azurelinux/busybox:1.36", +# "execProcesses": [ +# { +# "command": [ +# "sleep", +# "infinity" +# ] +# } +# ], +# "environmentVariables": [ +# { +# "name": "PATH", +# "value": "/another/customized/path/value" +# }, +# { +# "name": "TEST_REGEXP_ENV2", +# "value": "test_regexp_env2(.*)", +# "regex": true +# } +# ] +# } +# }, +# { +# "name": "my-image2", +# "properties": { +# "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", +# "execProcesses": [ +# { +# "command": [ +# "echo", +# "Hello World" +# ] +# } +# ], +# "volumeMounts": [ +# { +# "name": "azurefile", +# "mountPath": "/mount/azurefile", +# "mountType": "azureFile", +# "readOnly": true +# } +# ], +# "environmentVariables": [ +# { +# "name": "PATH", +# "value": "/customized/path/value" +# }, +# { +# "name": "TEST_REGEXP_ENV", +# "value": "test_regexp_env(.*)", +# "regex": true +# } +# ] +# } +# } +# ] +# } +# """ + +# custom_json3 = """ +# { +# "version": "1.0", +# "fragments": [ +# ], +# "containers": [ +# { +# "name": "my-image", +# "properties": { +# "image": "localhost:5000/helloworld:2.9", +# "execProcesses": [ +# { +# "command": [ +# "echo", +# "Hello World" +# ] +# } +# ], +# "volumeMounts": [ +# { +# "name": "azurefile", +# "mountPath": "/mount/azurefile", +# "mountType": "azureFile", +# "readOnly": true +# } +# ], +# "environmentVariables": [ +# { +# "name": "PATH", +# "value": "/customized/path/value" +# }, +# { +# "name": "TEST_REGEXP_ENV", +# "value": "test_regexp_env(.*)", +# "regex": true +# } +# ] +# } +# } +# ] +# } +# """ + +# @classmethod +# def setUpClass(cls): +# # start the zot registry +# cls.zot_image = "ghcr.io/project-zot/zot-linux-amd64:latest" +# cls.registry = "localhost:5000" +# registry_name = "myregistry" + +# # Initialize Docker client +# try: +# with DockerClient() as client: +# client.images.pull(cls.zot_image) + +# # Replace output = subprocess.run("docker ps -a", capture_output=True, shell=True) +# # Check if container already exists +# existing_containers = client.containers.list(all=True, filters={"name": registry_name}) + +# # Replace subprocess.run(f"docker run --name {registry_name} -d -p 5000:5000 {cls.zot_image}", shell=True) +# if not existing_containers: +# try: +# client.containers.run( +# cls.zot_image, +# name=registry_name, +# ports={'5000/tcp': 5000}, +# detach=True +# ) +# except docker.errors.APIError as e: +# raise Exception(f"Error starting registry container: {e}") +# else: +# # Start the container if it exists but is not running +# container = existing_containers[0] +# if container.status != 'running': +# container.start() +# except docker.errors.ImageNotFound: +# raise Exception(f"Could not pull image {cls.zot_image}") +# except docker.errors.APIError as e: +# raise Exception(f"Error pulling image {cls.zot_image}: {e}") +# except docker.errors.DockerException as e: +# raise Exception(f"Docker is not available: {e}") + +# cls.key_dir_parent = os.path.join(SAMPLES_DIR, '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_json(cls.custom_json) as aci_policy: +# aci_policy.populate_policy_content_for_all_images() +# cls.aci_policy = aci_policy +# with load_policy_from_json(cls.custom_json2) as aci_policy2: +# aci_policy2.populate_policy_content_for_all_images() +# cls.aci_policy2 = aci_policy2 + +# # stall while we wait for the registry to start running +# result = requests.get(f"http://{cls.registry}/v2/_catalog") +# counter = 0 +# retry_limit = 10 +# while result.status_code != 200: +# time.sleep(1) +# result = requests.get(f"http://{cls.registry}/v2/_catalog") +# counter += 1 +# if counter == retry_limit: +# raise Exception("Could not start local registry in time") + + +# def test_generate_import_from_remote(self): +# filename = "payload5.rego" +# feed = f"{self.registry}/test_feed:test_tag" +# algo = "ES384" +# out_path = filename + ".cose" + +# fragment_text = self.aci_policy.generate_fragment("payload4", "1", OutputType.RAW) +# temp_filename = "temp.json" +# 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) +# push_fragment_to_registry(feed, out_path) + +# # this should download and create the import statement +# acifragmentgen_confcom(None, None, None, None, None, None, None, None, "1", generate_import=True, fragment_path=feed, fragments_json=temp_filename) +# import_file = load_json_from_file(temp_filename) +# import_statement = import_file.get(config.ACI_FIELD_CONTAINERS_REGO_FRAGMENTS)[0] + +# 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, out_path, temp_filename]) + + # def test_remote_fragment_references(self): + # filename = "payload6.rego" + # filename2 = "payload7.rego" + # first_fragment = "first_fragment.json" + # fragment_json = "fragment_remote.json" + # feed = f"{self.registry}/test_feed:v1" + # feed2 = f"{self.registry}/test_feed2:v2" + # out_path = filename + ".cose" + # out_path2 = filename2 + ".cose" + + # # fragment_text = self.aci_policy.generate_fragment("payload6", 1, OutputType.RAW) + + # try: + # write_str_to_file(first_fragment, self.custom_json) + # write_str_to_file(fragment_json, self.custom_json2) + # acifragmentgen_confcom( + # None, first_fragment, None, "payload7", "1", feed, self.key, self.chain, None, output_filename=filename + # ) + + # # 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 + # ) + + # push_fragment_to_registry(feed, out_path) + + # acifragmentgen_confcom( + # None, fragment_json, None, "payload7", "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(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.9" in rego_str) + # except Exception as e: + # raise e + # finally: + # delete_silently([filename, out_path, filename2, out_path2, fragment_json, first_fragment]) + + # def test_incorrect_minimum_svn(self): + # filename = "payload8.rego" + # filename2 = "payload9.rego" + # fragment_json = "fragment.json" + # feed = f"{self.registry}/test_feed:v3" + # feed2 = f"{self.registry}/test_feed2:v4" + # algo = "ES384" + # out_path = filename + ".cose" + # out_path2 = filename2 + ".cose" + + # fragment_text = self.aci_policy.generate_fragment("payload8", "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="2", fragments_json=fragment_json, fragment_path=out_path + # ) + # # put the "path" field into the import statement + # push_fragment_to_registry(feed, out_path) + # acifragmentgen_confcom( + # None, fragment_json, None, "payload9", "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 is in the second fragment because the svn prevents usage + # self.assertTrue("mcr.microsoft.com/acc/samples/aci/helloworld:2.9" in rego_str) + # except Exception as e: + # raise e + # finally: + # delete_silently([filename, out_path, filename2, out_path2, fragment_json]) + + # def test_image_attached_fragment_coverage(self): + # # Initialize Docker client + # try: + # with DockerClient() as client: + # # Replace subprocess.run(f"docker tag mcr.microsoft.com/acc/samples/aci/helloworld:2.9 {self.registry}/helloworld:2.9", shell=True) + # try: + # source_image = client.images.get("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") + # source_image.tag(f"{self.registry}/helloworld:2.9") + # except docker.errors.ImageNotFound: + # # Try to pull the image first + # try: + # client.images.pull("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") + # source_image = client.images.get("mcr.microsoft.com/acc/samples/aci/helloworld:2.9") + # source_image.tag(f"{self.registry}/helloworld:2.9") + # except docker.errors.APIError as e: + # raise Exception(f"Could not pull or tag image: {e}") + # except docker.errors.APIError as e: + # raise Exception(f"Error tagging image: {e}") + + # # Replace subprocess.run(f"docker push {self.registry}/helloworld:2.9", timeout=30, shell=True) + # try: + # # Note: Docker SDK push returns a generator of status updates + # push_logs = client.images.push(f"{self.registry}/helloworld:2.9", stream=True, decode=True) + # # Consume the generator to ensure push completes + # for log in push_logs: + # if 'error' in log: + # raise Exception(f"Push failed: {log['error']}") + # except docker.errors.APIError as e: + # raise Exception(f"Error pushing image: {e}") + # except docker.errors.DockerException as e: + # raise Exception(f"Docker is not available: {e}") + + # filename = "container_image_attached.json" + # rego_filename = "temp_namespace" + # try: + # write_str_to_file(filename, self.custom_json3) + # acifragmentgen_confcom( + # None, + # filename, + # None, + # rego_filename, + # "1", + # "temp_feed", + # self.key, + # self.chain, + # "1", + # f"{self.registry}/helloworld:2.9", + # upload_fragment=True, + # ) + + + # # this will insert the import statement into the original container.json + # acifragmentgen_confcom( + # f"{self.registry}/helloworld:2.9", None, None, None, None, None, None, None, generate_import=True, minimum_svn="1", fragments_json=filename + # ) + + # # try to generate the policy again to make sure there are no containers in the resulting rego + # with self.assertRaises(SystemExit) as exc_info: + # acifragmentgen_confcom( + # None, + # filename, + # None, + # "temp_namespace2", + # "1", + # "temp_feed2", + # None, + # None, + # "1", + # f"{self.registry}/helloworld:2.9", + # ) + # self.assertEqual(exc_info.exception.code, 1) + + # except Exception as e: + # raise e + # finally: + # force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}.rego.cose"]) + + # def test_incorrect_pull_location(self): + # with self.assertRaises(SystemExit) as exc_info: + # _ = pull(f"{self.registry}/fake_artifact") + # self.assertEqual(exc_info.exception.code, 1) + + # def test_reserved_namespace(self): + # filename = "container_image_attached2.json" + # rego_filename = "policy" + # with self.assertRaises(CLIError) as exc_info: + # try: + # write_str_to_file(filename, self.custom_json) + # self.cmd(f"confcom acifragmentgen -i {filename} --namespace policy --svn 1") + # except Exception as e: + # raise e + # finally: + # force_delete_silently([filename, f"{rego_filename}.rego"]) + + # def test_invalid_svn(self): + # filename = "container_image_attached3.json" + # rego_filename = "myfile" + # with self.assertRaises(CLIError) as exc_info: + # try: + # write_str_to_file(filename, self.custom_json) + # self.cmd(f"confcom acifragmentgen -i {filename} --namespace policy --svn 0") + # except Exception as e: + # raise e + # finally: + # force_delete_silently([filename, f"{rego_filename}.rego"]) + +# class ExtendedFragmentTests(ScenarioTest): +# """Extended test cases for fragment operations following the requested scenarios.""" + +# custom_json_multi_container = """ +# { +# "version": "1.0", +# "fragments": [], +# "containers": [ +# { +# "name": "first-container", +# "properties": { +# "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", +# "execProcesses": [ +# { +# "command": ["echo", "First Container"] +# } +# ], +# "environmentVariables": [ +# { +# "name": "CONTAINER_TYPE", +# "value": "first" +# } +# ] +# } +# }, +# { +# "name": "second-container", +# "properties": { +# "image": "mcr.microsoft.com/azurelinux/busybox:1.36", +# "execProcesses": [ +# { +# "command": ["echo", "Second Container"] +# } +# ], +# "environmentVariables": [ +# { +# "name": "CONTAINER_TYPE", +# "value": "second" +# } +# ] +# } +# } +# ] +# } +# """ + +# custom_json_stdio_disabled = """ +# { +# "version": "1.0", +# "fragments": [], +# "containers": [ +# { +# "name": "stdio-disabled-container", +# "properties": { +# "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", +# "execProcesses": [ +# { +# "command": ["echo", "No stdio access"] +# } +# ], +# "environmentVariables": [ +# { +# "name": "STDIO_DISABLED", +# "value": "true" +# } +# ] +# } +# } +# ] +# } +# """ + +# @classmethod +# def setUpClass(cls): +# cls.registry = "localhost:5000" +# cls.key_dir_parent = os.path.join(SAMPLES_DIR, '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') + + # def test_upload_signed_fragment_to_registry(self): + # """Test uploading a signed fragment to the registry.""" + # filename = "signed_fragment.rego" + # feed = f"{self.registry}/signed_fragment:v1" + # algo = "ES384" + # out_path = filename + ".cose" + + # try: + # # Create a simple fragment policy + # with load_policy_from_json(self.custom_json_multi_container) as policy: + # policy.populate_policy_content_for_all_images() + # fragment_text = policy.generate_fragment("signed_fragment", "1", OutputType.RAW) + + # write_str_to_file(filename, fragment_text) + + # # Sign and upload to registry + # cose_proxy = CoseSignToolProxy() + # iss = cose_proxy.create_issuer(self.chain) + # cose_proxy.cose_sign(filename, self.key, self.chain, feed, iss, algo, out_path) + + # # Upload to registry + # push_fragment_to_registry(feed, out_path) + + # # Verify we can pull it back + # pulled_fragment = pull(feed) + # self.assertIsNotNone(pulled_fragment) + + # except Exception as e: + # raise e + # finally: + # force_delete_silently([filename, out_path]) + + # def test_attach_fragment_to_different_image(self): + # """Test attaching a fragment to a different image than the one it was created for.""" + # filename = "different_image_fragment.json" + # rego_filename = "different_image_rego" + + # try: + # write_str_to_file(filename, self.custom_json_multi_container) + + # # Create fragment for first image but try to attach to second + # acifragmentgen_confcom( + # None, + # filename, + # None, + # rego_filename, + # "1", + # "test_feed_different", + # self.key, + # self.chain, + # "1", + # "mcr.microsoft.com/azurelinux/busybox:1.36", # Different from the one in JSON + # upload_fragment=False, + # ) + + # # Verify the fragment was created + # self.assertTrue(os.path.exists(f"{rego_filename}.rego")) + + # except Exception as e: + # raise e + # finally: + # force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}.rego.cose"]) + +# def test_remote_pull_failure_path(self): +# """Test failure path when trying to pull from non-existent registry location.""" +# with self.assertRaises(SystemExit) as exc_info: +# _ = pull(f"{self.registry}/nonexistent_fragment:v1") +# self.assertEqual(exc_info.exception.code, 1) + +# def test_mixed_fragments_and_standalone_fragments_import(self): +# """Test import JSON with both 'fragments' and 'standaloneFragments' sections.""" +# mixed_json = """ +# { +# "version": "1.0", +# "fragments": [ +# { +# "feed": "localhost:5000/fragment1:v1", +# "issuer": "test_issuer", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# } +# ], +# "standaloneFragments": [ +# { +# "feed": "localhost:5000/standalone1:v1", +# "issuer": "test_issuer", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# } +# ], +# "containers": [] +# } +# """ + +# filename = "mixed_fragments.json" +# try: +# write_str_to_file(filename, mixed_json) + +# # This should handle both fragment types +# with load_policy_from_json(mixed_json) as policy: +# policy.populate_policy_content_for_all_images() +# output = policy.generate_fragment("mixed_fragments", "1", OutputType.RAW) +# self.assertIsNotNone(output) + +# except Exception as e: +# raise e +# finally: +# force_delete_silently(filename) + +# def test_import_json_as_array(self): +# """Test import JSON that is just an array instead of object.""" +# array_json = """ +# [ +# { +# "feed": "localhost:5000/array_fragment:v1", +# "issuer": "test_issuer", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# } +# ] +# """ + +# filename = "array_import.json" +# try: +# write_str_to_file(filename, array_json) + +# # This should fail or handle gracefully +# with self.assertRaises((ValueError, CLIError, SystemExit)): +# with load_policy_from_json(array_json) as policy: +# policy.populate_policy_content_for_all_images() + +# except Exception as e: +# raise e +# finally: +# force_delete_silently(filename) + +# def test_disable_stdio_access(self): +# """Test fragment generation with stdio access disabled.""" +# filename = "stdio_disabled.json" +# rego_filename = "stdio_disabled_rego" + +# try: +# write_str_to_file(filename, self.custom_json_stdio_disabled) + +# acifragmentgen_confcom( +# None, +# filename, +# None, +# rego_filename, +# "1", +# "stdio_test_feed", +# None, +# None, +# None, +# disable_stdio=True, +# ) + +# # Verify stdio access is disabled in the generated policy +# rego_content = load_str_from_file(f"{rego_filename}.rego") +# containers, _ = decompose_confidential_properties(str_to_base64(rego_content)) + +# # Check that stdio access is disabled +# self.assertFalse(containers[0].get("allow_stdio_access", True)) + +# except Exception as e: +# raise e +# finally: +# force_delete_silently([filename, f"{rego_filename}.rego"]) + +# def test_tar_input_processing(self): +# """Test processing tar and tar-mapping inputs.""" +# tar_filename = "test_input.tar" +# mapping_filename = "test_mapping.json" + +# try: +# # Create a simple tar mapping +# tar_mapping = { +# "tar_file": tar_filename, +# "containers": [ +# { +# "name": "tar-container", +# "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9" +# } +# ] +# } + +# write_str_to_file(mapping_filename, json.dumps(tar_mapping)) + +# # Create empty tar file for testing +# with open(tar_filename, 'wb') as f: +# f.write(b'') + +# # This should handle tar input gracefully or fail with appropriate error +# with self.assertRaises((FileNotFoundError, CLIError, SystemExit)): +# acifragmentgen_confcom( +# None, +# mapping_filename, +# tar_filename, +# "tar_test_rego", +# "1", +# "tar_test_feed", +# None, +# None, +# None +# ) + +# except Exception as e: +# raise e +# finally: +# force_delete_silently([tar_filename, mapping_filename]) + +# def test_fragment_target_image_consistency(self): +# """Test that fragment always lands on the intended image with and without --image-target.""" +# filename = "target_consistency.json" +# rego_filename = "target_consistency_rego" +# target_image = "mcr.microsoft.com/acc/samples/aci/helloworld:2.9" + +# try: +# write_str_to_file(filename, self.custom_json_multi_container) + +# # Test with explicit image target +# acifragmentgen_confcom( +# None, +# filename, +# None, +# rego_filename, +# "1", +# "target_test_feed", +# None, +# None, +# "1", +# target_image +# ) + +# # Verify the fragment contains the correct image +# rego_content = load_str_from_file(f"{rego_filename}.rego") +# self.assertIn(target_image, rego_content) + +# # Test without explicit image target (should use from JSON) +# acifragmentgen_confcom( +# None, +# filename, +# None, +# f"{rego_filename}_no_target", +# "1", +# "target_test_feed_2", +# None, +# None, +# None +# ) + +# # Should contain images from JSON +# rego_content_2 = load_str_from_file(f"{rego_filename}_no_target.rego") +# self.assertIn("mcr.microsoft.com/acc/samples/aci/helloworld:2.9", rego_content_2) + +# except Exception as e: +# raise e +# finally: +# force_delete_silently([filename, f"{rego_filename}.rego", f"{rego_filename}_no_target.rego"]) + +# def test_two_imports_same_feed_different_namespaces(self): +# """Test two imports that reference the same feed but expect different namespaces.""" +# # Note: This is actually testing an edge case where the same feed is referenced +# # but the system might expect different namespace handling +# container_json = """ +# { +# "version": "1.0", +# "containers": [ +# { +# "name": "shared-feed-container", +# "properties": { +# "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", +# "execProcesses": [ +# { +# "command": ["echo", "Shared Feed Test"] +# } +# ], +# "environmentVariables": [ +# { +# "name": "SHARED_FEED_TEST", +# "value": "true" +# } +# ] +# } +# } +# ] +# } +# """ + +# feed = f"{self.registry}/shared_feed:v1" +# namespace = "actual_namespace" + +# fragment_file = "shared_feed_fragment.rego" +# container_file = "shared_feed_container.json" +# import_file = "same_feed_diff_namespace.json" + +# try: +# write_str_to_file(container_file, container_json) + +# # Create and push fragment with a specific namespace +# with load_policy_from_json(container_json) as policy: +# policy.populate_policy_content_for_all_images() +# fragment_text = policy.generate_fragment(namespace, "1", OutputType.RAW) + +# write_str_to_file(fragment_file, fragment_text) + +# # Sign and push fragment +# cose_proxy = CoseSignToolProxy() +# iss = cose_proxy.create_issuer(self.chain) +# cose_proxy.cose_sign(fragment_file, self.key, self.chain, feed, iss, "ES384", fragment_file + ".cose") +# push_fragment_to_registry(feed, fragment_file + ".cose") + +# # Create import JSON that references the same feed twice +# # The system should handle this gracefully since it's the same fragment +# import_json = f""" +# {{ +# "version": "1.0", +# "fragments": [ +# {{ +# "feed": "{feed}", +# "issuer": "{iss}", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# }}, +# {{ +# "feed": "{feed}", +# "issuer": "{iss}", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# }} +# ], +# "containers": [] +# }} +# """ + +# write_str_to_file(import_file, import_json) + +# # This should work since it's the same feed/fragment being referenced twice +# with load_policy_from_json(import_json) as policy: +# policy.populate_policy_content_for_all_images() +# output = policy.generate_fragment("same_feed_diff_namespace", "1", OutputType.RAW) +# self.assertIsNotNone(output) + +# except Exception as e: +# raise e +# finally: +# force_delete_silently([container_file, fragment_file, fragment_file + ".cose", import_file]) + +# def test_two_imports_same_feed_and_namespace(self): +# """Test two imports that share both feed and namespace.""" +# # Create a single fragment and try to import it twice +# container_json = """ +# { +# "version": "1.0", +# "containers": [ +# { +# "name": "duplicate-test-container", +# "properties": { +# "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", +# "execProcesses": [ +# { +# "command": ["echo", "Duplicate Test"] +# } +# ], +# "environmentVariables": [ +# { +# "name": "DUPLICATE_TEST", +# "value": "true" +# } +# ] +# } +# } +# ] +# } +# """ + +# feed = f"{self.registry}/duplicate_feed:v1" +# namespace = "duplicate_namespace" + +# fragment_file = "duplicate_fragment.rego" +# container_file = "duplicate_container.json" +# import_file = "duplicate_imports.json" + +# try: +# write_str_to_file(container_file, container_json) + +# # Create and push fragment +# with load_policy_from_json(container_json) as policy: +# policy.populate_policy_content_for_all_images() +# fragment_text = policy.generate_fragment(namespace, "1", OutputType.RAW) + +# write_str_to_file(fragment_file, fragment_text) + +# # Sign and push fragment +# cose_proxy = CoseSignToolProxy() +# iss = cose_proxy.create_issuer(self.chain) +# cose_proxy.cose_sign(fragment_file, self.key, self.chain, feed, iss, "ES384", fragment_file + ".cose") +# push_fragment_to_registry(feed, fragment_file + ".cose") + +# # Create import JSON that references the same fragment twice +# import_json = f""" +# {{ +# "version": "1.0", +# "fragments": [ +# {{ +# "feed": "{feed}", +# "issuer": "{iss}", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# }}, +# {{ +# "feed": "{feed}", +# "issuer": "{iss}", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# }} +# ], +# "containers": [] +# }} +# """ + +# write_str_to_file(import_file, import_json) + +# # This should either deduplicate gracefully or handle duplicate imports appropriately +# with load_policy_from_json(import_json) as policy: +# policy.populate_policy_content_for_all_images() +# output = policy.generate_fragment("duplicate_imports", "1", OutputType.RAW) +# self.assertIsNotNone(output) + +# except Exception as e: +# raise e +# finally: +# force_delete_silently([container_file, fragment_file, fragment_file + ".cose", import_file]) + +# def test_two_imports_same_namespace_different_feeds(self): +# """Test two imports that share namespace but have different feeds.""" +# # Create two fragments with the same namespace but different feeds +# container_json = """ +# { +# "version": "1.0", +# "containers": [ +# { +# "name": "test-container", +# "properties": { +# "image": "mcr.microsoft.com/acc/samples/aci/helloworld:2.9", +# "execProcesses": [ +# { +# "command": ["echo", "Hello World"] +# } +# ], +# "environmentVariables": [ +# { +# "name": "PATH", +# "value": "/usr/local/bin" +# } +# ] +# } +# } +# ] +# } +# """ + +# feed1 = f"{self.registry}/feed1:v1" +# feed2 = f"{self.registry}/feed2:v1" +# same_namespace = "conflicting_namespace" + +# fragment1_file = "fragment1.rego" +# fragment2_file = "fragment2.rego" +# container_file = "container.json" +# import_file = "same_namespace_diff_feeds.json" + +# try: +# write_str_to_file(container_file, container_json) + +# # Create first fragment with specific namespace +# with load_policy_from_json(container_json) as policy: +# policy.populate_policy_content_for_all_images() +# fragment1_text = policy.generate_fragment(same_namespace, "1", OutputType.RAW) + +# write_str_to_file(fragment1_file, fragment1_text) + +# # Sign and push first fragment +# cose_proxy = CoseSignToolProxy() +# iss = cose_proxy.create_issuer(self.chain) +# cose_proxy.cose_sign(fragment1_file, self.key, self.chain, feed1, iss, "ES384", fragment1_file + ".cose") +# push_fragment_to_registry(feed1, fragment1_file + ".cose") + +# # Create second fragment with same namespace +# fragment2_text = policy.generate_fragment(same_namespace, "1", OutputType.RAW) +# write_str_to_file(fragment2_file, fragment2_text) + +# # Sign and push second fragment +# cose_proxy.cose_sign(fragment2_file, self.key, self.chain, feed2, iss, "ES384", fragment2_file + ".cose") +# push_fragment_to_registry(feed2, fragment2_file + ".cose") + +# # Create import JSON that references both fragments +# import_json = f""" +# {{ +# "version": "1.0", +# "fragments": [ +# {{ +# "feed": "{feed1}", +# "issuer": "{iss}", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# }}, +# {{ +# "feed": "{feed2}", +# "issuer": "{iss}", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# }} +# ], +# "containers": [] +# }} +# """ + +# write_str_to_file(import_file, import_json) + +# # This should fail due to namespace conflict when fragments are evaluated +# with self.assertRaises((CLIError, SystemExit, ValueError)): +# acipolicygen_confcom( +# import_file, +# None, +# None, +# None, +# None, +# None, +# None, +# None, +# None, +# include_fragments=True, +# ) + +# except Exception as e: +# raise e +# finally: +# force_delete_silently([container_file, fragment1_file, fragment1_file + ".cose", fragment2_file, fragment2_file + ".cose", import_file]) + +# def test_mixed_case_feed_namespace_strings(self): +# """Test handling of mixed case feed and namespace strings.""" +# mixed_case_json = """ +# { +# "version": "1.0", +# "fragments": [ +# { +# "feed": "localhost:5000/MixedCase_Feed:V1", +# "issuer": "Test_Issuer", +# "minimum_svn": "1", +# "includes": ["containers", "fragments"] +# } +# ], +# "containers": [] +# } +# """ + +# filename = "mixed_case.json" +# try: +# write_str_to_file(filename, mixed_case_json) + +# with load_policy_from_json(mixed_case_json) as policy: +# policy.populate_policy_content_for_all_images() +# output = policy.generate_fragment("MixedCase_NameSpace", "1", OutputType.RAW) +# self.assertIsNotNone(output) + +# # Verify case is preserved +# self.assertIn("MixedCase_Feed", output) +# self.assertIn("MixedCase_NameSpace", output) + +# except Exception as e: +# raise e +# finally: +# force_delete_silently(filename) class InitialFragmentErrors(ScenarioTest): From c09996e58d847993e691165c0b6921ae6eb6dff1 Mon Sep 17 00:00:00 2001 From: SethHollandsworth Date: Thu, 28 Aug 2025 19:56:56 -0400 Subject: [PATCH 5/5] pull image before saving to tar --- src/confcom/azext_confcom/tests/latest/test_confcom_tar.py | 1 + 1 file changed, 1 insertion(+) 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 ee58400974b..ab2733745f5 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py @@ -23,6 +23,7 @@ def create_tar_file(image_path: str) -> None: if not os.path.isfile(image_path): with DockerClient() as client: + client.images.pull("mcr.microsoft.com/aks/e2e/library-busybox", tag="master.220314.1-linux-amd64") image = client.images.get("mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64") f = open(image_path, "wb") for chunk in image.save(named=True):