From 18665a38f3f2bd640e119f8271b6670afc532131 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 10 Dec 2025 15:48:48 +0000 Subject: [PATCH 01/24] Remove the depedency on OPA --- src/confcom/azext_confcom/lib/opa.py | 62 ------------------ .../azext_confcom/lib/serialization.py | 64 +++++++++++++++---- src/confcom/setup.py | 2 - 3 files changed, 50 insertions(+), 78 deletions(-) delete mode 100644 src/confcom/azext_confcom/lib/opa.py diff --git a/src/confcom/azext_confcom/lib/opa.py b/src/confcom/azext_confcom/lib/opa.py deleted file mode 100644 index 4b1fa5150d5..00000000000 --- a/src/confcom/azext_confcom/lib/opa.py +++ /dev/null @@ -1,62 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import platform -import requests -import hashlib -import json -import os -import subprocess - -from typing import Iterable -from pathlib import Path -from azext_confcom.lib.paths import get_binaries_dir - - -_binaries_dir = get_binaries_dir() -_opa_binaries = { - "Linux": { - "path": _binaries_dir / "opa", - "url": "https://github.com/open-policy-agent/opa/releases/download/v1.10.1/opa_linux_amd64", - "sha256": "fe8e191d44fec33db2a3d0ca788b9f83f866d980c5371063620c3c6822792877", - }, - "Windows": { - "path": _binaries_dir / "opa.exe", - "url": "https://github.com/open-policy-agent/opa/releases/download/v1.10.1/opa_windows_amd64.exe", - "sha256": "4c932053350eabca47681208924046fbf3ad9de922d6853fb12cddf59aef15ce", - }, -} - - -def opa_get(): - - for binary_info in _opa_binaries.values(): - opa_fetch_resp = requests.get(binary_info["url"], verify=True) - opa_fetch_resp.raise_for_status() - - assert hashlib.sha256(opa_fetch_resp.content).hexdigest() == binary_info["sha256"] - - with open(binary_info["path"], "wb") as f: - f.write(opa_fetch_resp.content) - - os.chmod(binary_info["path"], 0o755) - - -def opa_run(args: Iterable[str]) -> subprocess.CompletedProcess: - return subprocess.run( - [_opa_binaries[platform.system()]["path"], *args], - check=True, - stdout=subprocess.PIPE, - text=True, - ) - - -def opa_eval(data_path: Path, query: str): - return json.loads(opa_run([ - "eval", - "--format", "json", - "--data", str(data_path), - query, - ]).stdout.strip()) diff --git a/src/confcom/azext_confcom/lib/serialization.py b/src/confcom/azext_confcom/lib/serialization.py index 4dcaab18bfe..53323d3f7af 100644 --- a/src/confcom/azext_confcom/lib/serialization.py +++ b/src/confcom/azext_confcom/lib/serialization.py @@ -10,7 +10,6 @@ from textwrap import dedent from typing import Union -from azext_confcom.lib.opa import opa_eval from azext_confcom.lib.policy import Container, FragmentReference, Fragment, Policy import re @@ -80,21 +79,58 @@ def fragment_serialize(fragment: Fragment): def policy_deserialize(file_path: str): with open(file_path, 'r') as f: - content = f.read() - - package_match = re.search(r'package\s+(\S+)', content) - package_name = package_match.group(1) - - PolicyType = Policy if package_name == "policy" else Fragment - - raw_json = opa_eval(Path(file_path), f"data.{package_name}")["result"][0]["expressions"][0]["value"] - - raw_fragments = raw_json.pop("fragments", []) - raw_containers = raw_json.pop("containers", []) + content = f.readlines() + + def _brace_delta(line: str) -> int: + delta = 0 + for char in line: + if char in ['{', '[', '(']: + delta += 1 + elif char in ['}', ']', ')']: + delta -= 1 + return delta + + policy_json = {} + line_idx = 0 + + while line_idx < len(content): + line = content[line_idx] + + packages_search = re.search(r'package\s+(\S+)', line) + if packages_search: + policy_json["package"] = packages_search.group(1) + line_idx += 1 + continue + + assignment = re.match(r"\s*(?P[A-Za-z0-9_]+)\s*:=\s*(?P.*)", line) + if assignment: + name = assignment.group('name') + expr = assignment.group('expr').strip() + expr_parts = [expr] + depth = _brace_delta(expr) + + while depth > 0 and line_idx + 1 < len(content): + line_idx += 1 + continuation = content[line_idx].strip() + expr_parts.append(continuation) + depth += _brace_delta(continuation) + + full_expr = "\n".join(expr_parts).strip().rstrip(",") + try: + policy_json[name] = json.loads(full_expr) + except json.JSONDecodeError: + # Skip non-literal expressions (e.g. data.framework bindings) + ... + + line_idx += 1 + + PolicyType = Policy if policy_json.get("package") == "policy" else Fragment + + raw_fragments = policy_json.pop("fragments", []) + raw_containers = policy_json.pop("containers", []) return PolicyType( - package=package_name, + **policy_json, fragments=[FragmentReference(**fragment) for fragment in raw_fragments], containers=[Container(**container) for container in raw_containers], - **raw_json ) diff --git a/src/confcom/setup.py b/src/confcom/setup.py index 2f56737c440..580e6753725 100644 --- a/src/confcom/setup.py +++ b/src/confcom/setup.py @@ -11,7 +11,6 @@ from azext_confcom.rootfs_proxy import SecurityPolicyProxy from azext_confcom.kata_proxy import KataPolicyGenProxy from azext_confcom.cose_proxy import CoseSignToolProxy -from azext_confcom.lib.opa import opa_get try: from azure_bdist_wheel import cmdclass @@ -49,7 +48,6 @@ SecurityPolicyProxy.download_binaries() KataPolicyGenProxy.download_binaries() CoseSignToolProxy.download_binaries() -opa_get() with open("README.md", "r", encoding="utf-8") as f: README = f.read() From 74955ad91372a2b573050042923fcc2e1cc35da4 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 10 Dec 2025 15:52:03 +0000 Subject: [PATCH 02/24] Bump version --- src/confcom/HISTORY.rst | 4 ++++ src/confcom/setup.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst index 06434b1a04d..8ffb3997568 100644 --- a/src/confcom/HISTORY.rst +++ b/src/confcom/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +1.4.5 +++++++ +* Drop the dependency on OPA + 1.4.4 ++++++ * Improve the package building process diff --git a/src/confcom/setup.py b/src/confcom/setup.py index 580e6753725..7b8c1157a0d 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.4.4" +VERSION = "1.4.5" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 9b1a24f41f2d82a82b55d26479277c92898b84a4 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 10 Dec 2025 16:00:44 +0000 Subject: [PATCH 03/24] Organise imports --- src/confcom/azext_confcom/lib/serialization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/confcom/azext_confcom/lib/serialization.py b/src/confcom/azext_confcom/lib/serialization.py index 53323d3f7af..7702bfef8ba 100644 --- a/src/confcom/azext_confcom/lib/serialization.py +++ b/src/confcom/azext_confcom/lib/serialization.py @@ -4,14 +4,14 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from dataclasses import asdict import json -from pathlib import Path +import re + +from dataclasses import asdict from textwrap import dedent from typing import Union from azext_confcom.lib.policy import Container, FragmentReference, Fragment, Policy -import re # This is a single entrypoint for serializing both Policy and Fragment objects From 629c52965be9a24fc49f40176b551acb07ea109c Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 24 Sep 2025 11:20:18 +0000 Subject: [PATCH 04/24] Restore default behaviour of --upload-fragment and give new args for standalone fragments --- src/confcom/azext_confcom/_params.py | 8 +++ src/confcom/azext_confcom/custom.py | 21 ++++-- src/confcom/samples/certs/create_certchain.sh | 72 ++++++++++--------- 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index ccbea8d0091..9a7b682e80d 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -341,6 +341,14 @@ def load_arguments(self, _): help="Upload a policy fragment to a container registry", validator=validate_upload_fragment, ) + c.argument( + "push_fragment_to", + help="The reference to push the fragment to", + ) + c.argument( + "attach_fragment_to", + help="The image reference to attach the fragment to", + ) c.argument( "no_print", options_list=("--no-print",), diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index 2f90c796bbd..b33395abf09 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -253,6 +253,8 @@ def acifragmentgen_confcom( output_filename: str = "", outraw: bool = False, upload_fragment: bool = False, + push_fragment_to: Optional[str] = None, + attach_fragment_to: Optional[str] = None, no_print: bool = False, fragments_json: str = "", ): @@ -375,10 +377,21 @@ def acifragmentgen_confcom( out_path = filename + ".cose" cose_proxy.cose_sign(filename, key, chain, feed, iss, algo, out_path) - 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) + + # Preserve default behaviour established since version 1.1.0 of attaching + # the fragment to the first image specified in input + # (or --image-target if specified) + if upload_fragment: + oras_proxy.attach_fragment_to_image( + image_name=image_target or policy_images[0].containerImage, + filename=out_path, + ) + + if push_fragment_to: + oras_proxy.push_fragment_to_registry(push_fragment_to, out_path) + + if attach_fragment_to: + oras_proxy.attach_fragment_to_image(attach_fragment_to, out_path) def katapolicygen_confcom( diff --git a/src/confcom/samples/certs/create_certchain.sh b/src/confcom/samples/certs/create_certchain.sh index 5e94f4c6f4e..48575efef3f 100755 --- a/src/confcom/samples/certs/create_certchain.sh +++ b/src/confcom/samples/certs/create_certchain.sh @@ -3,87 +3,91 @@ OriginalPath=`pwd` RootPath=`realpath $(dirname $0)` -cd $RootPath +OutPath=${1:-$RootPath} + +mkdir -p $OutPath + +cd $OutPath # create dirs for root CA -mkdir -p $RootPath/rootCA/{certs,crl,newcerts,private,csr} -mkdir -p $RootPath/intermediateCA/{certs,crl,newcerts,private,csr} +mkdir -p $OutPath/rootCA/{certs,crl,newcerts,private,csr} +mkdir -p $OutPath/intermediateCA/{certs,crl,newcerts,private,csr} # create index files -echo 1000 > $RootPath/rootCA/serial -echo 1000 > $RootPath/intermediateCA/serial +echo 1000 > $OutPath/rootCA/serial +echo 1000 > $OutPath/intermediateCA/serial # create crlnumbers -echo 0100 > $RootPath/rootCA/crlnumber -echo 0100 > $RootPath/intermediateCA/crlnumber +echo 0100 > $OutPath/rootCA/crlnumber +echo 0100 > $OutPath/intermediateCA/crlnumber # create index files -touch $RootPath/rootCA/index.txt -touch $RootPath/intermediateCA/index.txt +touch $OutPath/rootCA/index.txt +touch $OutPath/intermediateCA/index.txt # NOTE: needed for testing -echo "unique_subject = no" >> $RootPath/rootCA/index.txt.attr -echo "unique_subject = no" >> $RootPath/intermediateCA/index.txt.attr +echo "unique_subject = no" >> $OutPath/rootCA/index.txt.attr +echo "unique_subject = no" >> $OutPath/intermediateCA/index.txt.attr # generate root key -openssl genrsa -out $RootPath/rootCA/private/ca.key.pem 4096 -chmod 400 $RootPath/rootCA/private/ca.key.pem +openssl genrsa -out $OutPath/rootCA/private/ca.key.pem 4096 +chmod 400 $OutPath/rootCA/private/ca.key.pem # view the key -# openssl rsa -noout -text -in $RootPath/rootCA/private/ca.key.pem +# openssl rsa -noout -text -in $OutPath/rootCA/private/ca.key.pem # generate root cert -openssl req -config openssl_root.cnf -key $RootPath/rootCA/private/ca.key.pem -new -x509 -days 7300 -sha256 -extensions v3_ca -out $RootPath/rootCA/certs/ca.cert.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Root CA" +openssl req -config $RootPath/openssl_root.cnf -key $OutPath/rootCA/private/ca.key.pem -new -x509 -days 7300 -sha256 -extensions v3_ca -out $OutPath/rootCA/certs/ca.cert.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Root CA" # change permissions on root key so it's not globally readable -chmod 644 $RootPath/rootCA/certs/ca.cert.pem +chmod 644 $OutPath/rootCA/certs/ca.cert.pem # verify root cert -openssl x509 -noout -text -in $RootPath/rootCA/certs/ca.cert.pem +openssl x509 -noout -text -in $OutPath/rootCA/certs/ca.cert.pem # generate intermediate key -openssl genrsa -out $RootPath/intermediateCA/private/intermediate.key.pem 4096 -chmod 600 $RootPath/intermediateCA/private/intermediate.key.pem +openssl genrsa -out $OutPath/intermediateCA/private/intermediate.key.pem 4096 +chmod 600 $OutPath/intermediateCA/private/intermediate.key.pem # make CSR for intermediate -openssl req -config openssl_intermediate.cnf -key $RootPath/intermediateCA/private/intermediate.key.pem -new -sha256 -out $RootPath/intermediateCA/certs/intermediate.csr.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Intermediate CA" +openssl req -config $RootPath/openssl_intermediate.cnf -key $OutPath/intermediateCA/private/intermediate.key.pem -new -sha256 -out $OutPath/intermediateCA/certs/intermediate.csr.pem -subj "/C=US/ST=Georgia/L=Atlanta/O=Microsoft/OU=ACCCT/CN=Intermediate CA" # sign intermediate cert with root -openssl ca -config openssl_root.cnf -extensions v3_intermediate_ca -days 3650 -notext -md sha256 -in $RootPath/intermediateCA/certs/intermediate.csr.pem -out $RootPath/intermediateCA/certs/intermediate.cert.pem -batch +openssl ca -config $RootPath/openssl_root.cnf -extensions v3_intermediate_ca -days 3650 -notext -md sha256 -in $OutPath/intermediateCA/certs/intermediate.csr.pem -out $OutPath/intermediateCA/certs/intermediate.cert.pem -batch # make it readable by everyone -chmod 644 $RootPath/intermediateCA/certs/intermediate.cert.pem +chmod 644 $OutPath/intermediateCA/certs/intermediate.cert.pem # print the cert -# openssl x509 -noout -text -in $RootPath/intermediateCA/certs/intermediate.cert.pem +# openssl x509 -noout -text -in $OutPath/intermediateCA/certs/intermediate.cert.pem # verify intermediate cert -openssl verify -CAfile $RootPath/rootCA/certs/ca.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem +openssl verify -CAfile $OutPath/rootCA/certs/ca.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem # create chain file -cat $RootPath/intermediateCA/certs/intermediate.cert.pem $RootPath/rootCA/certs/ca.cert.pem > $RootPath/intermediateCA/certs/ca-chain.cert.pem +cat $OutPath/intermediateCA/certs/intermediate.cert.pem $OutPath/rootCA/certs/ca.cert.pem > $OutPath/intermediateCA/certs/ca-chain.cert.pem # verify chain -openssl verify -CAfile $RootPath/intermediateCA/certs/ca-chain.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem +openssl verify -CAfile $OutPath/intermediateCA/certs/ca-chain.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem # create server key -openssl ecparam -out $RootPath/intermediateCA/private/www.contoso.com.key.pem -name secp384r1 -genkey -openssl pkcs8 -topk8 -nocrypt -in $RootPath/intermediateCA/private/www.contoso.com.key.pem -out $RootPath/intermediateCA/private/ec_p384_private.pem +openssl ecparam -out $OutPath/intermediateCA/private/www.contoso.com.key.pem -name secp384r1 -genkey +openssl pkcs8 -topk8 -nocrypt -in $OutPath/intermediateCA/private/www.contoso.com.key.pem -out $OutPath/intermediateCA/private/ec_p384_private.pem -chmod 600 $RootPath/intermediateCA/private/www.contoso.com.key.pem +chmod 600 $OutPath/intermediateCA/private/www.contoso.com.key.pem # create csr for server -openssl req -config openssl_intermediate.cnf -key $RootPath/intermediateCA/private/www.contoso.com.key.pem -new -sha384 -out $RootPath/intermediateCA/csr/www.contoso.com.csr.pem -batch +openssl req -config $RootPath/openssl_intermediate.cnf -key $OutPath/intermediateCA/private/www.contoso.com.key.pem -new -sha384 -out $OutPath/intermediateCA/csr/www.contoso.com.csr.pem -batch # sign server cert with intermediate key -openssl ca -config openssl_intermediate.cnf -extensions server_cert -days 375 -notext -md sha384 -in $RootPath/intermediateCA/csr/www.contoso.com.csr.pem -out $RootPath/intermediateCA/certs/www.contoso.com.cert.pem -batch +openssl ca -config $RootPath/openssl_intermediate.cnf -extensions server_cert -days 375 -notext -md sha384 -in $OutPath/intermediateCA/csr/www.contoso.com.csr.pem -out $OutPath/intermediateCA/certs/www.contoso.com.cert.pem -batch # print the cert -# openssl x509 -noout -text -in $RootPath/intermediateCA/certs/www.contoso.com.cert.pem +# openssl x509 -noout -text -in $OutPath/intermediateCA/certs/www.contoso.com.cert.pem # make a public key -# openssl x509 -pubkey -noout -in $RootPath/intermediateCA/certs/www.contoso.com.cert.pem -out $RootPath/intermediateCA/certs/pubkey.pem +# openssl x509 -pubkey -noout -in $OutPath/intermediateCA/certs/www.contoso.com.cert.pem -out $OutPath/intermediateCA/certs/pubkey.pem # create chain file -cat $RootPath/intermediateCA/certs/www.contoso.com.cert.pem $RootPath/intermediateCA/certs/intermediate.cert.pem $RootPath/rootCA/certs/ca.cert.pem > $RootPath/intermediateCA/certs/www.contoso.com.chain.cert.pem +cat $OutPath/intermediateCA/certs/www.contoso.com.cert.pem $OutPath/intermediateCA/certs/intermediate.cert.pem $OutPath/rootCA/certs/ca.cert.pem > $OutPath/intermediateCA/certs/www.contoso.com.chain.cert.pem cd $OriginalPath \ No newline at end of file From e5f138da271a054727ab8e41fbbe1d365b2e1a4c Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 24 Sep 2025 14:27:42 +0000 Subject: [PATCH 05/24] Add testing to enforce behaviour --- .../latest/test_confcom_acifragmentgen.py | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py new file mode 100644 index 00000000000..211aece3cac --- /dev/null +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -0,0 +1,205 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from itertools import product +import json +import os +import subprocess +import tempfile +import pytest +import docker + +from azext_confcom.custom import acifragmentgen_confcom + +TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +SAMPLES_DIR = os.path.abspath(os.path.join(TEST_DIR, "..", "..", "..", "samples")) + + +@pytest.fixture() +def docker_image(): + + client = docker.from_env() + + registry_container = client.containers.run( + image="registry:2", + detach=True, + ports={"5000/tcp": 0}, + ) + registry_container.reload() + registry_port = registry_container.attrs['NetworkSettings']['Ports']['5000/tcp'][0]['HostPort'] + + test_container_ref = f"localhost:{registry_port}/hello-world:latest" + client.images.pull("hello-world").tag(test_container_ref) + client.images.push(test_container_ref) + + with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as temp_file: + json.dump({ + "version": "1.0.0", + "containers": [ + { + "name": "hello-world", + "properties": { + "image": test_container_ref, + }, + } + ] + }, temp_file) + temp_file.flush() + + yield test_container_ref, temp_file.name + + registry_container.stop() + registry_container.remove() + + +@pytest.fixture(scope="session") +def cert_chain(): + with tempfile.TemporaryDirectory(delete=True) as temp_dir: + subprocess.run( + [ + os.path.join(SAMPLES_DIR, "certs", "create_certchain.sh"), + temp_dir + ], + check=True, + ) + yield temp_dir + + +def test_acifragmentgen_fragment_gen(docker_image): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=None, + chain=None, + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + output_filename=os.path.join(temp_dir, "fragment.rego"), + ) + + # TODO: Implement a proper validation for the fragment, this is hard + # because each test run will have a unique image to have unique local + # registries on different ports + + +def test_acifragmentgen_fragment_sign(docker_image, cert_chain): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + output_filename=os.path.join(temp_dir, "fragment.rego"), + ) + + # TODO: Implement a proper validation for the cose document + + +def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + upload_fragment=True, + output_filename=os.path.relpath(os.path.join(temp_dir, "fragment.rego"), os.getcwd()), # Must be relative for oras + ) + + oras_referrers = subprocess.run( + ["oras", "discover", image_ref], + stdout=subprocess.PIPE, + text=True, + check=True + ).stdout + + # Confirm the fragment is attached to the image + assert "application/x-ms-ccepolicy-frag" in oras_referrers + + +def test_acifragmentgen_fragment_push(docker_image, cert_chain): + + image_ref, spec_file_path = docker_image + fragment_ref = image_ref.replace("hello-world", "fragment") + + with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + push_fragment_to=fragment_ref, + output_filename=os.path.relpath(os.path.join(temp_dir, "fragment.rego"), os.getcwd()), # Must be relative for oras + ) + + # Confirm the fragment exists in the registry + subprocess.run( + ["oras", "discover", fragment_ref], + stdout=subprocess.PIPE, + text=True, + check=True, + ).stdout + + +def test_acifragmentgen_fragment_attach(docker_image, cert_chain): + + image_ref, spec_file_path = docker_image + + with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + outraw=True, + attach_fragment_to=image_ref, + output_filename=os.path.relpath(os.path.join(temp_dir, "fragment.rego"), os.getcwd()), # Must be relative for oras + ) + + oras_referrers = subprocess.run( + ["oras", "discover", image_ref], + stdout=subprocess.PIPE, + text=True, + check=True + ).stdout + + # Confirm the fragment is attached to the image + assert "application/x-ms-ccepolicy-frag" in oras_referrers, oras_referrers From 093cbd5de16c694a06d6bf29fa2faadbc0651e14 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 24 Sep 2025 15:28:21 +0000 Subject: [PATCH 06/24] Test possible fixes for ado pipeline failure --- .../tests/latest/test_confcom_acifragmentgen.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index 211aece3cac..730df20e2aa 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -8,6 +8,7 @@ import os import subprocess import tempfile +import time import pytest import docker @@ -27,12 +28,15 @@ def docker_image(): detach=True, ports={"5000/tcp": 0}, ) + time.sleep(10) # TODO: Replace with polling registry_container.reload() registry_port = registry_container.attrs['NetworkSettings']['Ports']['5000/tcp'][0]['HostPort'] - test_container_ref = f"localhost:{registry_port}/hello-world:latest" - client.images.pull("hello-world").tag(test_container_ref) - client.images.push(test_container_ref) + test_container_repo = f"localhost:{registry_port}/hello-world" + test_container_tag = "latest" + test_container_ref = f"{test_container_repo}:{test_container_tag}" + client.images.pull("hello-world").tag(repository=test_container_repo, tag=test_container_tag) + client.images.push(repository=test_container_repo, tag=test_container_tag) with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as temp_file: json.dump({ From 77e246193acbd67cb4fcb41fb34f20aa6d47ac7f Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 24 Sep 2025 15:51:16 +0000 Subject: [PATCH 07/24] Avoid using localhost for docker operations --- .../azext_confcom/tests/latest/test_confcom_acifragmentgen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index 730df20e2aa..98650990275 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -32,9 +32,9 @@ def docker_image(): registry_container.reload() registry_port = registry_container.attrs['NetworkSettings']['Ports']['5000/tcp'][0]['HostPort'] - test_container_repo = f"localhost:{registry_port}/hello-world" + test_container_repo = f"127.0.0.1:{registry_port}/hello-world" test_container_tag = "latest" - test_container_ref = f"{test_container_repo}:{test_container_tag}" + test_container_ref = f"localhost:{registry_port}/hello-world:{test_container_tag}" client.images.pull("hello-world").tag(repository=test_container_repo, tag=test_container_tag) client.images.push(repository=test_container_repo, tag=test_container_tag) From 078fea2491f8b49be5d68d53e91cbbaea1466cee Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 24 Sep 2025 16:08:35 +0000 Subject: [PATCH 08/24] Remove arg for TemporaryDirectory which is only in newer python version --- .../tests/latest/test_confcom_acifragmentgen.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index 98650990275..12ff9ce47d4 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -60,7 +60,7 @@ def docker_image(): @pytest.fixture(scope="session") def cert_chain(): - with tempfile.TemporaryDirectory(delete=True) as temp_dir: + with tempfile.TemporaryDirectory() as temp_dir: subprocess.run( [ os.path.join(SAMPLES_DIR, "certs", "create_certchain.sh"), @@ -75,7 +75,7 @@ def test_acifragmentgen_fragment_gen(docker_image): image_ref, spec_file_path = docker_image - with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo acifragmentgen_confcom( image_name=None, tar_mapping_location=None, @@ -99,7 +99,7 @@ def test_acifragmentgen_fragment_sign(docker_image, cert_chain): image_ref, spec_file_path = docker_image - with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo acifragmentgen_confcom( image_name=None, tar_mapping_location=None, @@ -121,7 +121,7 @@ def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): image_ref, spec_file_path = docker_image - with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo acifragmentgen_confcom( image_name=None, tar_mapping_location=None, @@ -153,7 +153,7 @@ def test_acifragmentgen_fragment_push(docker_image, cert_chain): image_ref, spec_file_path = docker_image fragment_ref = image_ref.replace("hello-world", "fragment") - with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo acifragmentgen_confcom( image_name=None, tar_mapping_location=None, @@ -182,7 +182,7 @@ def test_acifragmentgen_fragment_attach(docker_image, cert_chain): image_ref, spec_file_path = docker_image - with tempfile.TemporaryDirectory(delete=True) as temp_dir: # Prevent test writing files to repo + with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo acifragmentgen_confcom( image_name=None, tar_mapping_location=None, From 27f0ef451ace3d49ae3da1fe85099a49aaba4339 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 24 Sep 2025 16:42:37 +0000 Subject: [PATCH 09/24] Replace docker python SDK with CLI --- .../latest/test_confcom_acifragmentgen.py | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index 12ff9ce47d4..c9f3028ab37 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -3,14 +3,11 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from itertools import product import json import os import subprocess import tempfile -import time import pytest -import docker from azext_confcom.custom import acifragmentgen_confcom @@ -21,22 +18,22 @@ @pytest.fixture() def docker_image(): - client = docker.from_env() + registry_id = subprocess.run( + ["docker", "run", "-d", "-p", "0:5000", "registry:2"], + stdout=subprocess.PIPE, + text=True, + ).stdout - registry_container = client.containers.run( - image="registry:2", - detach=True, - ports={"5000/tcp": 0}, - ) - time.sleep(10) # TODO: Replace with polling - registry_container.reload() - registry_port = registry_container.attrs['NetworkSettings']['Ports']['5000/tcp'][0]['HostPort'] + registry_port = subprocess.run( + ["docker", "port", registry_id], + stdout=subprocess.PIPE, + text=True, + ).stdout.split(":")[-1].strip() - test_container_repo = f"127.0.0.1:{registry_port}/hello-world" - test_container_tag = "latest" - test_container_ref = f"localhost:{registry_port}/hello-world:{test_container_tag}" - client.images.pull("hello-world").tag(repository=test_container_repo, tag=test_container_tag) - client.images.push(repository=test_container_repo, tag=test_container_tag) + test_container_ref = f"localhost:{registry_port}/hello-world:latest" + subprocess.run(["docker", "pull", "hello-world"]) + subprocess.run(["docker", "tag", "hello-world", test_container_ref]) + subprocess.run(["docker", "push", test_container_ref]) with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", delete=True) as temp_file: json.dump({ @@ -54,8 +51,7 @@ def docker_image(): yield test_container_ref, temp_file.name - registry_container.stop() - registry_container.remove() + subprocess.run(["docker", "stop", registry_id]) @pytest.fixture(scope="session") From fa8741682123bece3d18d0dfe34638d087d66f54 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 24 Sep 2025 17:11:41 +0000 Subject: [PATCH 10/24] Bump the version of confcom --- src/confcom/HISTORY.rst | 6 ++++++ src/confcom/setup.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/confcom/HISTORY.rst b/src/confcom/HISTORY.rst index 8ffb3997568..80a04fc5975 100644 --- a/src/confcom/HISTORY.rst +++ b/src/confcom/HISTORY.rst @@ -3,6 +3,12 @@ Release History =============== +1.5.0 +++++++ +* restored the behaviour of --upload-fragment in acifragmentgen to attach to first image in input +* added --push-fragment-to flag to acifragmentgen to allow explicit uploading of standalone fragments +* added --attach-fragment-to flag to acifragmentgen to allow explicit uploading of image attached fragments + 1.4.5 ++++++ * Drop the dependency on OPA diff --git a/src/confcom/setup.py b/src/confcom/setup.py index 7b8c1157a0d..fe40522e879 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.4.5" +VERSION = "1.5.0" # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 1093eed4e2032700d9bb5da4186f67265140ad48 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 14:28:29 +0000 Subject: [PATCH 11/24] Split fragment push and fragment attach into standalone tools --- src/confcom/azext_confcom/_params.py | 43 +++++- .../azext_confcom/command/fragment_attach.py | 42 ++++++ .../azext_confcom/command/fragment_push.py | 42 ++++++ src/confcom/azext_confcom/commands.py | 4 + src/confcom/azext_confcom/custom.py | 43 ++++-- .../latest/test_confcom_acifragmentgen.py | 139 +++++++++++------- .../tests/latest/test_confcom_fragment.py | 6 +- 7 files changed, 246 insertions(+), 73 deletions(-) create mode 100644 src/confcom/azext_confcom/command/fragment_attach.py create mode 100644 src/confcom/azext_confcom/command/fragment_push.py diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index 9a7b682e80d..d75ce70abc1 100644 --- a/src/confcom/azext_confcom/_params.py +++ b/src/confcom/azext_confcom/_params.py @@ -5,6 +5,8 @@ # pylint: disable=line-too-long import json +import argparse +import sys from knack.arguments import CLIArgumentType from azext_confcom._validators import ( validate_params_file, @@ -44,6 +46,32 @@ def load_arguments(self, _): c.argument("tags", tags_type) c.argument("confcom_name", confcom_name_type, options_list=["--name", "-n"]) + with self.argument_context("confcom fragment attach") as c: + c.positional( + "signed_fragment", + nargs='?', + type=argparse.FileType('rb'), + default=sys.stdin.buffer, + help="Signed fragment to attach", + ) + c.argument( + "manifest_tag", + help="Manifest tag for the fragment", + ) + + with self.argument_context("confcom fragment push") as c: + c.positional( + "signed_fragment", + nargs='?', + type=argparse.FileType('rb'), + default=sys.stdin.buffer, + help="Signed fragment to push", + ) + c.argument( + "manifest_tag", + help="Manifest tag for the fragment", + ) + with self.argument_context("confcom acipolicygen") as c: c.argument( "input_path", @@ -341,14 +369,6 @@ def load_arguments(self, _): help="Upload a policy fragment to a container registry", validator=validate_upload_fragment, ) - c.argument( - "push_fragment_to", - help="The reference to push the fragment to", - ) - c.argument( - "attach_fragment_to", - help="The image reference to attach the fragment to", - ) c.argument( "no_print", options_list=("--no-print",), @@ -370,6 +390,13 @@ def load_arguments(self, _): type=json.loads, help='Container definitions to include in the policy' ) + c.argument( + "out_signed_fragment", + action="store_true", + default=False, + required=False, + help="Emit only the signed fragment bytes", + ) with self.argument_context("confcom katapolicygen") as c: c.argument( diff --git a/src/confcom/azext_confcom/command/fragment_attach.py b/src/confcom/azext_confcom/command/fragment_attach.py new file mode 100644 index 00000000000..928dbc345e3 --- /dev/null +++ b/src/confcom/azext_confcom/command/fragment_attach.py @@ -0,0 +1,42 @@ + +import os +import subprocess +import tempfile +from typing import BinaryIO + + +def oras_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + subprocess.run( + [ + "oras", + "attach", + "--artifact-type", "application/x-ms-ccepolicy-frag", + manifest_tag, + os.path.relpath(signed_fragment.name, start=os.getcwd()), + ], + check=True, + timeout=120, + ) + + +def fragment_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + + if signed_fragment.name == "": + with tempfile.NamedTemporaryFile(delete=True) as temp_signed_fragment: + temp_signed_fragment.write(signed_fragment.read()) + temp_signed_fragment.flush() + oras_attach( + signed_fragment=temp_signed_fragment, + manifest_tag=manifest_tag, + ) + else: + oras_attach( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag, + ) diff --git a/src/confcom/azext_confcom/command/fragment_push.py b/src/confcom/azext_confcom/command/fragment_push.py new file mode 100644 index 00000000000..1f90b57fab5 --- /dev/null +++ b/src/confcom/azext_confcom/command/fragment_push.py @@ -0,0 +1,42 @@ + +import os +import subprocess +import tempfile +from typing import BinaryIO + + +def oras_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + subprocess.run( + [ + "oras", + "push", + "--artifact-type", "application/x-ms-ccepolicy-frag", + manifest_tag, + os.path.relpath(signed_fragment.name, start=os.getcwd()), + ], + check=True, + timeout=120, + ) + + +def fragment_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + + if signed_fragment.name == "": + with tempfile.NamedTemporaryFile(delete=True) as temp_signed_fragment: + temp_signed_fragment.write(signed_fragment.read()) + temp_signed_fragment.flush() + oras_push( + signed_fragment=temp_signed_fragment, + manifest_tag=manifest_tag, + ) + else: + oras_push( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag, + ) diff --git a/src/confcom/azext_confcom/commands.py b/src/confcom/azext_confcom/commands.py index 1d2bb45f724..7e1e93eabca 100644 --- a/src/confcom/azext_confcom/commands.py +++ b/src/confcom/azext_confcom/commands.py @@ -11,5 +11,9 @@ def load_command_table(self, _): g.custom_command("acifragmentgen", "acifragmentgen_confcom") g.custom_command("katapolicygen", "katapolicygen_confcom") + with self.command_group("confcom fragment") as g: + g.custom_command("attach", "fragment_attach", is_preview=True) + g.custom_command("push", "fragment_push", is_preview=True) + with self.command_group("confcom"): pass diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index b33395abf09..438416f9c69 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -5,7 +5,7 @@ import os import sys -from typing import Optional +from typing import BinaryIO from azext_confcom import oras_proxy, os_util, security_policy from azext_confcom._validators import resolve_stdio @@ -22,6 +22,8 @@ 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 azext_confcom.command.fragment_attach import fragment_attach as _fragment_attach +from azext_confcom.command.fragment_push import fragment_push as _fragment_push from knack.log import get_logger from pkg_resources import parse_version @@ -253,10 +255,9 @@ def acifragmentgen_confcom( output_filename: str = "", outraw: bool = False, upload_fragment: bool = False, - push_fragment_to: Optional[str] = None, - attach_fragment_to: Optional[str] = None, no_print: bool = False, fragments_json: str = "", + out_signed_fragment: bool = False, ): if container_definitions is None: container_definitions = [] @@ -363,12 +364,16 @@ def acifragmentgen_confcom( fragment_text = policy.generate_fragment(namespace, svn, output_type, omit_id=omit_id) - if output_type != security_policy.OutputType.DEFAULT and not no_print: + if output_type != security_policy.OutputType.DEFAULT and not no_print and not out_signed_fragment: print(fragment_text) # take ".rego" off the end of the filename if it's there, it'll get added back later output_filename = output_filename.replace(".rego", "") filename = f"{output_filename or namespace}.rego" + + if out_signed_fragment: + filename = os.path.join("/tmp", filename) + os_util.write_str_to_file(filename, fragment_text) if key: @@ -376,6 +381,9 @@ def acifragmentgen_confcom( iss = cose_proxy.create_issuer(chain) out_path = filename + ".cose" + if out_signed_fragment: + out_path = os.path.join("/tmp", os.path.basename(out_path)) + cose_proxy.cose_sign(filename, key, chain, feed, iss, algo, out_path) # Preserve default behaviour established since version 1.1.0 of attaching @@ -387,11 +395,8 @@ def acifragmentgen_confcom( filename=out_path, ) - if push_fragment_to: - oras_proxy.push_fragment_to_registry(push_fragment_to, out_path) - - if attach_fragment_to: - oras_proxy.attach_fragment_to_image(attach_fragment_to, out_path) + if out_signed_fragment: + sys.stdout.buffer.write(open(out_path, "rb").read()) def katapolicygen_confcom( @@ -525,3 +530,23 @@ def get_fragment_output_type(outraw): if outraw: output_type = security_policy.OutputType.RAW return output_type + + +def fragment_attach( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + _fragment_attach( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag + ) + + +def fragment_push( + signed_fragment: BinaryIO, + manifest_tag: str, +) -> None: + _fragment_push( + signed_fragment=signed_fragment, + manifest_tag=manifest_tag + ) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index c9f3028ab37..4f1c09fdf65 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -3,13 +3,15 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import contextlib +import io import json import os import subprocess import tempfile import pytest -from azext_confcom.custom import acifragmentgen_confcom +from azext_confcom.custom import acifragmentgen_confcom, fragment_push, fragment_attach TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) SAMPLES_DIR = os.path.abspath(os.path.join(TEST_DIR, "..", "..", "..", "samples")) @@ -84,6 +86,7 @@ def test_acifragmentgen_fragment_gen(docker_image): feed="test-feed", outraw=True, output_filename=os.path.join(temp_dir, "fragment.rego"), + out_signed_fragment=False, ) # TODO: Implement a proper validation for the fragment, this is hard @@ -108,6 +111,7 @@ def test_acifragmentgen_fragment_sign(docker_image, cert_chain): feed="test-feed", outraw=True, output_filename=os.path.join(temp_dir, "fragment.rego"), + out_signed_fragment=False, ) # TODO: Implement a proper validation for the cose document @@ -131,75 +135,104 @@ def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): outraw=True, upload_fragment=True, output_filename=os.path.relpath(os.path.join(temp_dir, "fragment.rego"), os.getcwd()), # Must be relative for oras + out_signed_fragment=False, ) - oras_referrers = subprocess.run( - ["oras", "discover", image_ref], - stdout=subprocess.PIPE, - text=True, - check=True - ).stdout + # Confirm the fragment exists and is attached in the registry + fragment_ref = json.loads(subprocess.run( + ["oras", "discover", image_ref, "--format", "json"], + stdout=subprocess.PIPE, + check=True, + ).stdout)["referrers"][0]["reference"] + + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + - # Confirm the fragment is attached to the image - assert "application/x-ms-ccepolicy-frag" in oras_referrers + with open(fragment_path, "rb") as actual_fragment_file: + with open(os.path.join(temp_dir, "fragment.rego.cose"), "rb") as expected_fragment_file: + assert actual_fragment_file.read() == expected_fragment_file.read() -def test_acifragmentgen_fragment_push(docker_image, cert_chain): +def test_acifragmentgen_fragment_push(docker_image, cert_chain, capsysbinary): image_ref, spec_file_path = docker_image fragment_ref = image_ref.replace("hello-world", "fragment") - with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo - acifragmentgen_confcom( - image_name=None, - tar_mapping_location=None, - key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), - chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), - minimum_svn=None, - input_path=spec_file_path, - svn="1", - namespace="contoso", - feed="test-feed", - outraw=True, - push_fragment_to=fragment_ref, - output_filename=os.path.relpath(os.path.join(temp_dir, "fragment.rego"), os.getcwd()), # Must be relative for oras - ) + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + out_signed_fragment=True, + ) + + signed_fragment = capsysbinary.readouterr()[0] + signed_fragment_io = io.BytesIO(signed_fragment) + signed_fragment_io.name = "" + + fragment_push( + signed_fragment=signed_fragment_io, + manifest_tag=fragment_ref, + ) # Confirm the fragment exists in the registry - subprocess.run( - ["oras", "discover", fragment_ref], - stdout=subprocess.PIPE, - text=True, + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], check=True, - ).stdout + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + with open(fragment_path, "rb") as f: + assert f.read() == signed_fragment -def test_acifragmentgen_fragment_attach(docker_image, cert_chain): +def test_acifragmentgen_fragment_attach(docker_image, cert_chain, capsysbinary): image_ref, spec_file_path = docker_image - with tempfile.TemporaryDirectory() as temp_dir: # Prevent test writing files to repo - acifragmentgen_confcom( - image_name=None, - tar_mapping_location=None, - key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), - chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), - minimum_svn=None, - input_path=spec_file_path, - svn="1", - namespace="contoso", - feed="test-feed", - outraw=True, - attach_fragment_to=image_ref, - output_filename=os.path.relpath(os.path.join(temp_dir, "fragment.rego"), os.getcwd()), # Must be relative for oras - ) + acifragmentgen_confcom( + image_name=None, + tar_mapping_location=None, + key=os.path.join(cert_chain, "intermediateCA", "private", "ec_p384_private.pem"), + chain=os.path.join(cert_chain, "intermediateCA", "certs", "www.contoso.com.chain.cert.pem"), + minimum_svn=None, + input_path=spec_file_path, + svn="1", + namespace="contoso", + feed="test-feed", + out_signed_fragment=True, + ) + + signed_fragment = capsysbinary.readouterr()[0] + signed_fragment_io = io.BytesIO(signed_fragment) + signed_fragment_io.name = "" + + fragment_attach( + signed_fragment=signed_fragment_io, + manifest_tag=image_ref, + ) + + # Confirm the fragment exists and is attached in the registry + fragment_ref = json.loads(subprocess.run( + ["oras", "discover", image_ref, "--format", "json"], + stdout=subprocess.PIPE, + check=True, + ).stdout)["referrers"][0]["reference"] - oras_referrers = subprocess.run( - ["oras", "discover", image_ref], + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], + check=True, stdout=subprocess.PIPE, - text=True, - check=True - ).stdout + ).stdout)["files"][0]["path"] - # Confirm the fragment is attached to the image - assert "application/x-ms-ccepolicy-frag" in oras_referrers, oras_referrers + with open(fragment_path, "rb") as f: + assert f.read() == signed_fragment 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 2725ede31c0..3addfcee66e 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -493,7 +493,7 @@ 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") + filename2 = os.path.join(folder, "oci2.tar") tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename2} create_tar_file(filename) @@ -762,14 +762,14 @@ class FragmentPolicySigning(unittest.TestCase): """ @classmethod def setUpClass(cls): - cls.key_dir_parent = os.path.join(SAMPLES_DIR, 'certs') + cls.key_dir_parent = "/tmp/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, + script_path, cls.key_dir_parent ] os.chmod(script_path, 0o755) From c3cce0502f5ee61f05d160d8df49e2be6c43af50 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 16:12:48 +0000 Subject: [PATCH 12/24] Undo changes --- .../azext_confcom/tests/latest/test_confcom_fragment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 3addfcee66e..24402abf7f2 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -762,14 +762,14 @@ class FragmentPolicySigning(unittest.TestCase): """ @classmethod def setUpClass(cls): - cls.key_dir_parent = "/tmp/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): script_path = os.path.join(cls.key_dir_parent, 'create_certchain.sh') arg_list = [ - script_path, cls.key_dir_parent + script_path, ] os.chmod(script_path, 0o755) From 3e661d64ac17e236f38e834f14f4b022630e533a Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 16:16:00 +0000 Subject: [PATCH 13/24] Print some debug info --- .../tests/latest/test_confcom_acifragmentgen.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index 4f1c09fdf65..ddb2107029b 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -139,11 +139,13 @@ def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): ) # Confirm the fragment exists and is attached in the registry - fragment_ref = json.loads(subprocess.run( + oras_result = subprocess.run( ["oras", "discover", image_ref, "--format", "json"], stdout=subprocess.PIPE, check=True, - ).stdout)["referrers"][0]["reference"] + ).stdout + print(f"{oras_result.decode('utf-8')=}") + fragment_ref = json.loads(oras_result)["referrers"][0]["reference"] fragment_path = json.loads(subprocess.run( ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], From 5c5a8bfe35510f91bede80eea7b6f20c1e47012e Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 16:16:55 +0000 Subject: [PATCH 14/24] Add missing licenses --- src/confcom/azext_confcom/command/fragment_attach.py | 4 ++++ src/confcom/azext_confcom/command/fragment_push.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/confcom/azext_confcom/command/fragment_attach.py b/src/confcom/azext_confcom/command/fragment_attach.py index 928dbc345e3..39f29ae48da 100644 --- a/src/confcom/azext_confcom/command/fragment_attach.py +++ b/src/confcom/azext_confcom/command/fragment_attach.py @@ -1,3 +1,7 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- import os import subprocess diff --git a/src/confcom/azext_confcom/command/fragment_push.py b/src/confcom/azext_confcom/command/fragment_push.py index 1f90b57fab5..89912c87637 100644 --- a/src/confcom/azext_confcom/command/fragment_push.py +++ b/src/confcom/azext_confcom/command/fragment_push.py @@ -1,3 +1,7 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- import os import subprocess From 4aef917486e515aa8ae35b39f4442849b1cb32ae Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 17:39:05 +0000 Subject: [PATCH 15/24] Handle case with attached fragments --- .../latest/test_confcom_acifragmentgen.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index ddb2107029b..d966f94e497 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -139,13 +139,16 @@ def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): ) # Confirm the fragment exists and is attached in the registry - oras_result = subprocess.run( + oras_result = json.loads(subprocess.run( ["oras", "discover", image_ref, "--format", "json"], stdout=subprocess.PIPE, check=True, - ).stdout - print(f"{oras_result.decode('utf-8')=}") - fragment_ref = json.loads(oras_result)["referrers"][0]["reference"] + ).stdout) + + if oras_result.get("artifact_type") == "application/x-ms-ccepolicy-frag": + fragment_ref = oras_result["reference"] + else: + fragment_ref = oras_result["referrers"][0]["reference"] fragment_path = json.loads(subprocess.run( ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], @@ -224,11 +227,16 @@ def test_acifragmentgen_fragment_attach(docker_image, cert_chain, capsysbinary): ) # Confirm the fragment exists and is attached in the registry - fragment_ref = json.loads(subprocess.run( + oras_result = json.loads(subprocess.run( ["oras", "discover", image_ref, "--format", "json"], stdout=subprocess.PIPE, check=True, - ).stdout)["referrers"][0]["reference"] + ).stdout) + + if oras_result.get("artifact_type") == "application/x-ms-ccepolicy-frag": + fragment_ref = oras_result["reference"] + else: + fragment_ref = oras_result["referrers"][0]["reference"] fragment_path = json.loads(subprocess.run( ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], From d23c67103bf8c634317e0819102709e1947fa9a6 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 18:36:29 +0000 Subject: [PATCH 16/24] Add fallback debug info --- .../tests/latest/test_confcom_acifragmentgen.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index d966f94e497..59242701a89 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -147,8 +147,10 @@ def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): if oras_result.get("artifact_type") == "application/x-ms-ccepolicy-frag": fragment_ref = oras_result["reference"] - else: + elif "referrers" in oras_result: fragment_ref = oras_result["referrers"][0]["reference"] + else: + raise AssertionError(f"{oras_result=}") fragment_path = json.loads(subprocess.run( ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], @@ -235,8 +237,10 @@ def test_acifragmentgen_fragment_attach(docker_image, cert_chain, capsysbinary): if oras_result.get("artifact_type") == "application/x-ms-ccepolicy-frag": fragment_ref = oras_result["reference"] - else: + elif "referrers" in oras_result: fragment_ref = oras_result["referrers"][0]["reference"] + else: + raise AssertionError(f"{oras_result=}") fragment_path = json.loads(subprocess.run( ["oras", "pull", fragment_ref, "--format", "json", "-o", "/tmp"], From d3c978f43c535c97855c334af63456dc80a1443d Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 19:55:26 +0000 Subject: [PATCH 17/24] Fix check --- .../tests/latest/test_confcom_acifragmentgen.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index 59242701a89..97e3901e454 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -145,10 +145,10 @@ def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): check=True, ).stdout) - if oras_result.get("artifact_type") == "application/x-ms-ccepolicy-frag": - fragment_ref = oras_result["reference"] - elif "referrers" in oras_result: + if "referrers" in oras_result: fragment_ref = oras_result["referrers"][0]["reference"] + elif oras_result.get("manifests")[0].get("artifact_type") == "application/x-ms-ccepolicy-frag": + fragment_ref = oras_result["reference"] else: raise AssertionError(f"{oras_result=}") @@ -235,10 +235,10 @@ def test_acifragmentgen_fragment_attach(docker_image, cert_chain, capsysbinary): check=True, ).stdout) - if oras_result.get("artifact_type") == "application/x-ms-ccepolicy-frag": - fragment_ref = oras_result["reference"] - elif "referrers" in oras_result: + if "referrers" in oras_result: fragment_ref = oras_result["referrers"][0]["reference"] + elif oras_result["manifests"][0].get("artifact_type") == "application/x-ms-ccepolicy-frag": + fragment_ref = oras_result["reference"] else: raise AssertionError(f"{oras_result=}") From 6ff639549724e07195c1ced2d387cf6e98b5db36 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 20:29:38 +0000 Subject: [PATCH 18/24] Fix typo --- .../azext_confcom/tests/latest/test_confcom_acifragmentgen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index 97e3901e454..12e56cc91b4 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -147,7 +147,7 @@ def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): if "referrers" in oras_result: fragment_ref = oras_result["referrers"][0]["reference"] - elif oras_result.get("manifests")[0].get("artifact_type") == "application/x-ms-ccepolicy-frag": + elif oras_result.get("manifests")[0].get("artifactType") == "application/x-ms-ccepolicy-frag": fragment_ref = oras_result["reference"] else: raise AssertionError(f"{oras_result=}") @@ -237,7 +237,7 @@ def test_acifragmentgen_fragment_attach(docker_image, cert_chain, capsysbinary): if "referrers" in oras_result: fragment_ref = oras_result["referrers"][0]["reference"] - elif oras_result["manifests"][0].get("artifact_type") == "application/x-ms-ccepolicy-frag": + elif oras_result["manifests"][0].get("artifactType") == "application/x-ms-ccepolicy-frag": fragment_ref = oras_result["reference"] else: raise AssertionError(f"{oras_result=}") From f82a9a4c1c6a20c9e7e3ddac46e5a182b1eb0abf Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Thu, 25 Sep 2025 20:50:23 +0000 Subject: [PATCH 19/24] Fix another typo --- .../azext_confcom/tests/latest/test_confcom_acifragmentgen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py index 12e56cc91b4..5426e8ec707 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -148,7 +148,7 @@ def test_acifragmentgen_fragment_upload_fragment(docker_image, cert_chain): if "referrers" in oras_result: fragment_ref = oras_result["referrers"][0]["reference"] elif oras_result.get("manifests")[0].get("artifactType") == "application/x-ms-ccepolicy-frag": - fragment_ref = oras_result["reference"] + fragment_ref = oras_result["manifests"][0]["reference"] else: raise AssertionError(f"{oras_result=}") @@ -238,7 +238,7 @@ def test_acifragmentgen_fragment_attach(docker_image, cert_chain, capsysbinary): if "referrers" in oras_result: fragment_ref = oras_result["referrers"][0]["reference"] elif oras_result["manifests"][0].get("artifactType") == "application/x-ms-ccepolicy-frag": - fragment_ref = oras_result["reference"] + fragment_ref = oras_result["manifests"][0]["reference"] else: raise AssertionError(f"{oras_result=}") From f825e6f0ac824942668e68df6e7fc84e588e3ad1 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Fri, 26 Sep 2025 07:55:11 +0000 Subject: [PATCH 20/24] Satisfy azdev linter --- linter_exclusions.yml | 12 +++++++++ src/confcom/azext_confcom/_help.py | 43 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/linter_exclusions.yml b/linter_exclusions.yml index ce4aaa82bca..5105cb7f34b 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -3504,3 +3504,15 @@ neon postgres organization: neon postgres project: rule_exclusions: - require_wait_command_if_no_wait + +confcom fragment push: + parameters: + signed_fragment: + rule_exclusions: + - no_positional_parameters + +confcom fragment attach: + parameters: + signed_fragment: + rule_exclusions: + - no_positional_parameters diff --git a/src/confcom/azext_confcom/_help.py b/src/confcom/azext_confcom/_help.py index 15368cc61db..5106d083a64 100644 --- a/src/confcom/azext_confcom/_help.py +++ b/src/confcom/azext_confcom/_help.py @@ -278,3 +278,46 @@ - name: Input a Kubernetes YAML file with a custom containerd socket path text: az confcom katapolicygen --yaml "./pod.json" --containerd-pull --containerd-socket-path "/my/custom/containerd.sock" """ + +helps[ + "confcom fragment" +] = """ + type: group + short-summary: Commands to handle Confidential Container Policy Fragments. +""" + +helps[ + "confcom fragment push" +] = """ + type: command + short-summary: Push a Confidential Container Policy Fragment to an ORAS registry + + parameters: + - name: --manifest-tag + type: string + short-summary: 'The reference to push the signed fragment to' + + examples: + - name: Push a signed fragment to a registry + text: az confcom fragment push ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/fragment:latest + - name: Push the output of acifragmentgen to a registry + text: az confcom acifragmentgen --chain my.cert.pem --key my_key.pem --svn "1" --namespace contoso --feed "test-feed" --input ./fragment_spec.json | az confcom fragment push --manifest-tag myregistry.azurecr.io/fragment:latest +""" + +helps[ + "confcom fragment attach" +] = """ + type: command + short-summary: Attach a Confidential Container Policy Fragment to an image in an ORAS registry. + + parameters: + - name: --manifest-tag + type: string + short-summary: 'The reference to attach the signed fragment to' + + examples: + - name: Attach a signed fragment to a registry + text: az confcom fragment attach ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/image:latest + - name: Attach the output of acifragmentgen to a registry + text: az confcom acifragmentgen --chain my.cert.pem --key my_key.pem --svn "1" --namespace contoso --feed "test-feed" --input ./fragment_spec.json | az confcom fragment attach --manifest-tag myregistry.azurecr.io/image:latest +""" \ No newline at end of file From 5c0f27efcaba6a58c7964810d490fb481c37ac6d Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Fri, 26 Sep 2025 07:59:50 +0000 Subject: [PATCH 21/24] Fix azdev style --- src/confcom/azext_confcom/_help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/confcom/azext_confcom/_help.py b/src/confcom/azext_confcom/_help.py index 5106d083a64..9817bef723e 100644 --- a/src/confcom/azext_confcom/_help.py +++ b/src/confcom/azext_confcom/_help.py @@ -320,4 +320,4 @@ text: az confcom fragment attach ./fragment.reg.cose --manifest-tag myregistry.azurecr.io/image:latest - name: Attach the output of acifragmentgen to a registry text: az confcom acifragmentgen --chain my.cert.pem --key my_key.pem --svn "1" --namespace contoso --feed "test-feed" --input ./fragment_spec.json | az confcom fragment attach --manifest-tag myregistry.azurecr.io/image:latest -""" \ No newline at end of file +""" From 3c907198e3835091ca409bd4f5592fc1d6fad409 Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Fri, 21 Nov 2025 15:26:58 +0000 Subject: [PATCH 22/24] Fix missing import --- src/confcom/azext_confcom/custom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/confcom/azext_confcom/custom.py b/src/confcom/azext_confcom/custom.py index 438416f9c69..045e1743dd6 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -5,7 +5,7 @@ import os import sys -from typing import BinaryIO +from typing import Optional, BinaryIO from azext_confcom import oras_proxy, os_util, security_policy from azext_confcom._validators import resolve_stdio From 3a510cb79b37e79f56cc79ccacda7f9407ef395a Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Tue, 25 Nov 2025 10:38:05 +0000 Subject: [PATCH 23/24] Fix race in tests --- .../tests/latest/test_confcom_arm.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py b/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py index cd5f0be05f6..c75d1aa3e8a 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_arm.py @@ -3,10 +3,13 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import fcntl import os import unittest import json import deepdiff +import docker +import requests from unittest.mock import patch from azext_confcom.security_policy import ( @@ -24,6 +27,7 @@ ) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +PRUNE_LOCK_PATH = "/tmp/confcom_docker_prune.lock" class PolicyGeneratingArm(unittest.TestCase): @@ -5007,7 +5011,18 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): - cls.client.containers.prune() + # Coordinate cleanup across xdist workers to avoid prune conflicts. + with open(PRUNE_LOCK_PATH, "w") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + cls.client.containers.prune() + except (docker.errors.APIError, requests.exceptions.ReadTimeout) as exc: + # Ignore conflicts (another prune in flight) or slow daemon timeouts. + status = getattr(getattr(exc, "response", None), "status_code", None) + if status not in (409, None) or not isinstance(exc, requests.exceptions.ReadTimeout): + raise + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) cls.client.close() def test_arm_template_security_context_no_run_as_group(self): From 62ad4f2ab476d90323fe10c1de7a4a950c41833c Mon Sep 17 00:00:00 2001 From: Dominic Ayre Date: Wed, 26 Nov 2025 14:13:03 +0000 Subject: [PATCH 24/24] Prevent tests changing tracked files --- .../data/genpolicy-settings.json | 338 ++++++++++++++++++ src/confcom/azext_confcom/data/rules.rego | 6 +- .../tests/latest/test_confcom_fragment.py | 10 +- .../tests/latest/test_confcom_tar.py | 2 +- .../tests/latest/test_confcom_virtual_node.py | 16 +- 5 files changed, 360 insertions(+), 12 deletions(-) create mode 100644 src/confcom/azext_confcom/data/genpolicy-settings.json diff --git a/src/confcom/azext_confcom/data/genpolicy-settings.json b/src/confcom/azext_confcom/data/genpolicy-settings.json new file mode 100644 index 00000000000..73d9c1125bb --- /dev/null +++ b/src/confcom/azext_confcom/data/genpolicy-settings.json @@ -0,0 +1,338 @@ +{ + "pause_container": { + "Root": { + "Path": "$(cpath)/$(bundle-id)", + "Readonly": true + }, + "Mounts": [ + { + "destination": "/dev/shm", + "type_": "bind", + "source": "/run/kata-containers/sandbox/shm", + "options": [ + "rbind" + ] + }, + { + "destination": "/etc/resolv.conf", + "type_": "bind", + "options": [ + "rbind", + "ro", + "nosuid", + "nodev", + "noexec" + ] + } + ], + "Annotations": { + "io.kubernetes.cri.container-type": "sandbox", + "io.kubernetes.cri.sandbox-id": "^[a-z0-9]{64}$", + "io.kubernetes.cri.sandbox-log-directory": "^/var/log/pods/$(sandbox-namespace)_$(sandbox-name)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "io.katacontainers.pkg.oci.container_type": "pod_sandbox", + "io.kubernetes.cri.sandbox-namespace": "default", + "io.katacontainers.pkg.oci.bundle_path": "/run/containerd/io.containerd.runtime.v2.task/k8s.io/$(bundle-id)" + }, + "Process": { + "Args": [ + "/pause" + ] + }, + "Linux": { + "MaskedPaths": [ + "/proc/acpi", + "/proc/asound", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/sys/firmware", + "/proc/scsi" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + } + }, + "other_container": { + "Root": { + "Path": "$(cpath)/$(bundle-id)" + }, + "Mounts": [ + { + "destination": "/etc/hosts", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "rw" + ] + }, + { + "destination": "/dev/termination-log", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "rw" + ] + }, + { + "destination": "/etc/hostname", + "type_": "bind", + "options": [ + "rbind", + "rprivate" + ] + }, + { + "destination": "/etc/resolv.conf", + "type_": "bind", + "options": [ + "rbind", + "rprivate" + ] + }, + { + "destination": "/dev/shm", + "type_": "bind", + "source": "/run/kata-containers/sandbox/shm", + "options": [ + "rbind" + ] + }, + { + "destination": "/var/run/secrets/kubernetes.io/serviceaccount", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + }, + { + "destination": "/var/run/secrets/azure/tokens", + "source": "$(sfprefix)tokens$", + "type_": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + } + ], + "Annotations": { + "io.katacontainers.pkg.oci.bundle_path": "/run/containerd/io.containerd.runtime.v2.task/k8s.io/$(bundle-id)", + "io.kubernetes.cri.sandbox-id": "^[a-z0-9]{64}$", + "io.katacontainers.pkg.oci.container_type": "pod_container", + "io.kubernetes.cri.container-type": "container" + } + }, + "volumes": { + "emptyDir": { + "mount_type": "local", + "mount_source": "^$(cpath)/$(sandbox-id)/local/", + "mount_point": "^$(cpath)/$(sandbox-id)/local/", + "driver": "local", + "source": "local", + "fstype": "local", + "options": [ + "mode=0777" + ] + }, + "emptyDir_memory": { + "mount_type": "bind", + "mount_source": "^/run/kata-containers/sandbox/ephemeral/", + "mount_point": "^/run/kata-containers/sandbox/ephemeral/", + "driver": "ephemeral", + "source": "tmpfs", + "fstype": "tmpfs", + "options": [] + }, + "configMap": { + "mount_type": "bind", + "mount_source": "$(sfprefix)", + "mount_point": "^$(cpath)/watchable/$(bundle-id)-[a-z0-9]{16}-", + "driver": "watchable-bind", + "fstype": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + }, + "confidential_configMap": { + "mount_type": "bind", + "mount_source": "$(sfprefix)", + "mount_point": "$(sfprefix)", + "driver": "local", + "fstype": "bind", + "options": [ + "rbind", + "rprivate", + "ro" + ] + } + }, + "mount_destinations": [ + "/sys/fs/cgroup", + "/etc/hosts", + "/dev/termination-log", + "/etc/hostname", + "/etc/resolv.conf", + "/dev/shm", + "/var/run/secrets/kubernetes.io/serviceaccount", + "/var/run/secrets/azure/tokens" + ], + "sandbox": { + "storages": [ + { + "driver": "ephemeral", + "driver_options": [], + "source": "shm", + "fstype": "tmpfs", + "options": [ + "noexec", + "nosuid", + "nodev", + "mode=1777", + "size=67108864" + ], + "mount_point": "/run/kata-containers/sandbox/shm", + "fs_group": null + } + ] + }, + "common": { + "cpath": "/run/kata-containers/shared/containers", + "sfprefix": "^$(cpath)/$(bundle-id)-[a-z0-9]{16}-", + "spath": "/run/kata-containers/sandbox/storage", + "ip_p": "[0-9]{1,5}", + "ipv4_a": "((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}", + "svc_name": "[A-Z0-9_\\.\\-]+", + "dns_label": "[a-zA-Z0-9_\\.\\-]+", + "s_source1": "^..2[0-9]{3}_[0-1][0-9]_[0-3][0-9]_[0-2][0-9]_[0-5][0-9]_[0-5][0-9]\\.[0-9]{1,10}$", + "s_source2": "^..data/", + "default_caps": [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_FSETID", + "CAP_FOWNER", + "CAP_MKNOD", + "CAP_NET_RAW", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETFCAP", + "CAP_SETPCAP", + "CAP_NET_BIND_SERVICE", + "CAP_SYS_CHROOT", + "CAP_KILL", + "CAP_AUDIT_WRITE" + ], + "privileged_caps": [ + "CAP_CHOWN", + "CAP_DAC_OVERRIDE", + "CAP_DAC_READ_SEARCH", + "CAP_FOWNER", + "CAP_FSETID", + "CAP_KILL", + "CAP_SETGID", + "CAP_SETUID", + "CAP_SETPCAP", + "CAP_LINUX_IMMUTABLE", + "CAP_NET_BIND_SERVICE", + "CAP_NET_BROADCAST", + "CAP_NET_ADMIN", + "CAP_NET_RAW", + "CAP_IPC_LOCK", + "CAP_IPC_OWNER", + "CAP_SYS_MODULE", + "CAP_SYS_RAWIO", + "CAP_SYS_CHROOT", + "CAP_SYS_PTRACE", + "CAP_SYS_PACCT", + "CAP_SYS_ADMIN", + "CAP_SYS_BOOT", + "CAP_SYS_NICE", + "CAP_SYS_RESOURCE", + "CAP_SYS_TIME", + "CAP_SYS_TTY_CONFIG", + "CAP_MKNOD", + "CAP_LEASE", + "CAP_AUDIT_WRITE", + "CAP_AUDIT_CONTROL", + "CAP_SETFCAP", + "CAP_MAC_OVERRIDE", + "CAP_MAC_ADMIN", + "CAP_SYSLOG", + "CAP_WAKE_ALARM", + "CAP_BLOCK_SUSPEND", + "CAP_AUDIT_READ", + "CAP_PERFMON", + "CAP_BPF", + "CAP_CHECKPOINT_RESTORE" + ], + "virtio_blk_storage_classes": [ + "cc-local-csi", + "cc-managed-csi", + "cc-managed-premium-csi" + ], + "smb_storage_classes": [ + { + "name": "azurefile-csi-kata-cc", + "mount_options": [ + "dir_mode=0777", + "file_mode=0777", + "mfsymlinks", + "cache=strict", + "nosharesock", + "actimeo=30", + "nobrl" + ] + } + ] + }, + "kata_config": { + "confidential_guest": true + }, + "cluster_config": { + "default_namespace": "default" + }, + "request_defaults": { + "CreateContainerRequest": { + "allow_env_regex": [ + "^HOSTNAME=$(dns_label)$", + "^$(svc_name)_PORT_$(ip_p)_TCP=tcp://$(ipv4_a):$(ip_p)$", + "^$(svc_name)_PORT_$(ip_p)_TCP_PROTO=tcp$", + "^$(svc_name)_PORT_$(ip_p)_TCP_PORT=$(ip_p)$", + "^$(svc_name)_PORT_$(ip_p)_TCP_ADDR=$(ipv4_a)$", + "^$(svc_name)_SERVICE_HOST=$(ipv4_a)$", + "^$(svc_name)_SERVICE_PORT=$(ip_p)$", + "^$(svc_name)_SERVICE_PORT_$(dns_label)=$(ip_p)$", + "^$(svc_name)_PORT=tcp://$(ipv4_a):$(ip_p)$", + "^AZURE_CLIENT_ID=[A-Fa-f0-9-]*$", + "^AZURE_TENANT_ID=[A-Fa-f0-9-]*$", + "^AZURE_FEDERATED_TOKEN_FILE=/var/run/secrets/azure/tokens/azure-identity-token$", + "^AZURE_AUTHORITY_HOST=https://login\\.microsoftonline\\.com/$", + "^TERM=xterm$" + ] + }, + "CopyFileRequest": [ + "$(sfprefix)" + ], + "ExecProcessRequest": { + "commands": [], + "regex": [] + }, + "CloseStdinRequest": false, + "ReadStreamRequest": true, + "UpdateEphemeralMountsRequest": false, + "WriteStreamRequest": false + } +} diff --git a/src/confcom/azext_confcom/data/rules.rego b/src/confcom/azext_confcom/data/rules.rego index a5208cf9d3b..4e4c3b3e03d 100644 --- a/src/confcom/azext_confcom/data/rules.rego +++ b/src/confcom/azext_confcom/data/rules.rego @@ -54,6 +54,7 @@ default AllowRequestsFailingPolicy := false # Constants S_NAME_KEY = "io.kubernetes.cri.sandbox-name" S_NAMESPACE_KEY = "io.kubernetes.cri.sandbox-namespace" +BUNDLE_ID = "[a-z0-9]{64}" CreateContainerRequest { # Check if the input request should be rejected even before checking the @@ -468,6 +469,9 @@ allow_by_bundle_or_sandbox_id(p_oci, i_oci, p_storages, i_storages) { bundle_path := i_oci.Annotations["io.katacontainers.pkg.oci.bundle_path"] bundle_id := replace(bundle_path, "/run/containerd/io.containerd.runtime.v2.task/k8s.io/", "") + bundle_id_format := concat("", ["^", BUNDLE_ID, "$"]) + regex.match(bundle_id_format, bundle_id) + key := "io.kubernetes.cri.sandbox-id" p_regex := p_oci.Annotations[key] @@ -1226,7 +1230,7 @@ CopyFileRequest { some regex1 in policy_data.request_defaults.CopyFileRequest regex2 := replace(regex1, "$(sfprefix)", policy_data.common.sfprefix) regex3 := replace(regex2, "$(cpath)", policy_data.common.cpath) - regex4 := replace(regex3, "$(bundle-id)", "[a-z0-9]{64}") + regex4 := replace(regex3, "$(bundle-id)", BUNDLE_ID) print("CopyFileRequest: regex4 =", regex4) regex.match(regex4, input.path) 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 24402abf7f2..66102f151da 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_fragment.py @@ -5,6 +5,7 @@ import json import os +from pathlib import Path import subprocess import tempfile import time @@ -762,14 +763,16 @@ class FragmentPolicySigning(unittest.TestCase): """ @classmethod def setUpClass(cls): - cls.key_dir_parent = os.path.join(SAMPLES_DIR, 'certs') + cls.key_dir_parent = Path(tempfile.gettempdir(), "certchain") + cls.key_dir_parent.mkdir(parents=True, exist_ok=True) 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') + script_path = os.path.join(SAMPLES_DIR, "certs", 'create_certchain.sh') arg_list = [ script_path, + cls.key_dir_parent.as_posix(), ] os.chmod(script_path, 0o755) @@ -777,8 +780,7 @@ def setUpClass(cls): item = subprocess.run( arg_list, check=False, - shell=True, - cwd=cls.key_dir_parent, + shell=False, env=os.environ.copy(), ) 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 ab2733745f5..1da2de3e90a 100644 --- a/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_tar.py @@ -175,7 +175,7 @@ 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") + filename2 = os.path.join(folder, "oci2.tar") tar_mapping_file = {"mcr.microsoft.com/aks/e2e/library-busybox:master.220314.1-linux-amd64": filename2} create_tar_file(filename) 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 c6e8ad4a23a..2c6a3ad8766 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 @@ -4,6 +4,8 @@ # -------------------------------------------------------------------------------------------- import os +from pathlib import Path +import tempfile import unittest import json import subprocess @@ -22,6 +24,7 @@ ) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +SAMPLES_DIR = os.path.abspath(os.path.join(TEST_DIR, "..", "..", "..", "samples")) class PolicyGeneratingVirtualNode(unittest.TestCase): @@ -338,17 +341,19 @@ class PolicyGeneratingVirtualNode(unittest.TestCase): ports: - containerPort: 80 name: web -""" + """ @classmethod def setUpClass(cls): - cls.key_dir_parent = os.path.join(TEST_DIR, '..', '..', '..', 'samples', 'certs') + cls.key_dir_parent = Path(tempfile.gettempdir(), "certchain") + cls.key_dir_parent.mkdir(parents=True, exist_ok=True) 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') + script_path = os.path.join(SAMPLES_DIR, "certs", 'create_certchain.sh') arg_list = [ script_path, + cls.key_dir_parent.as_posix(), ] os.chmod(script_path, 0o755) @@ -356,8 +361,7 @@ def setUpClass(cls): item = subprocess.run( arg_list, check=False, - shell=True, - cwd=cls.key_dir_parent, + shell=False, env=os.environ.copy(), ) @@ -534,4 +538,4 @@ def test_custom_args(self): containers = json.loads(extract_containers_from_text(virtual_node_policy.get_serialized_output(OutputType.PRETTY_PRINT), container_start)) command = containers[0].get("command") - self.assertEqual(command[-2:], ["test", "values"]) \ No newline at end of file + self.assertEqual(command[-2:], ["test", "values"])