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/HISTORY.rst b/src/confcom/HISTORY.rst index 8ffb3997568..70a2ef341f6 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 confcom fragment push command to allow explicit uploading of standalone fragments +* added confcom fragment attach command to allow explicit uploading of image attached fragments + 1.4.5 ++++++ * Drop the dependency on OPA diff --git a/src/confcom/azext_confcom/_help.py b/src/confcom/azext_confcom/_help.py index 15368cc61db..9817bef723e 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 +""" diff --git a/src/confcom/azext_confcom/_params.py b/src/confcom/azext_confcom/_params.py index ccbea8d0091..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", @@ -362,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/__init__.py b/src/confcom/azext_confcom/command/__init__.py new file mode 100644 index 00000000000..e69de29bb2d 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..39f29ae48da --- /dev/null +++ b/src/confcom/azext_confcom/command/fragment_attach.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# 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 +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..89912c87637 --- /dev/null +++ b/src/confcom/azext_confcom/command/fragment_push.py @@ -0,0 +1,46 @@ +# -------------------------------------------------------------------------------------------- +# 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 +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 2f90c796bbd..1b243a31370 100644 --- a/src/confcom/azext_confcom/custom.py +++ b/src/confcom/azext_confcom/custom.py @@ -5,7 +5,8 @@ import os import sys -from typing import Optional +import tempfile +from typing import Optional, BinaryIO from azext_confcom import oras_proxy, os_util, security_policy from azext_confcom._validators import resolve_stdio @@ -22,6 +23,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 @@ -255,6 +258,7 @@ def acifragmentgen_confcom( upload_fragment: bool = False, no_print: bool = False, fragments_json: str = "", + out_signed_fragment: bool = False, ): if container_definitions is None: container_definitions = [] @@ -361,12 +365,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(tempfile.gettempdir(), filename) + os_util.write_str_to_file(filename, fragment_text) if key: @@ -374,11 +382,23 @@ def acifragmentgen_confcom( iss = cose_proxy.create_issuer(chain) out_path = filename + ".cose" + if out_signed_fragment: + out_path = os.path.join(tempfile.gettempdir(), os.path.basename(out_path)) + 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 out_signed_fragment: + with open(out_path, "rb") as f: + sys.stdout.buffer.write(f.read()) def katapolicygen_confcom( @@ -512,3 +532,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/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/conftest.py b/src/confcom/azext_confcom/tests/conftest.py index abe3a1b6315..4fb3e58bee8 100644 --- a/src/confcom/azext_confcom/tests/conftest.py +++ b/src/confcom/azext_confcom/tests/conftest.py @@ -79,13 +79,16 @@ def run_on_wheel(request): with zipfile.ZipFile(wheel_path, "r") as z: z.extractall(expanded_dir) - sys.path.insert(0, expanded_dir.resolve().as_posix()) + for idx, path in enumerate(sys.path): + if "src/confcom" in path: + sys.path[idx] = expanded_dir.resolve().as_posix() for module in list(sys.modules.values()): if ( extension in module.__name__ and not any (m.__name__.startswith(module.__name__) for m in modules_to_test) ): del sys.modules[module.__name__] + assert importlib.util.find_spec(module.__name__).origin # This catches modules not in the built packages importlib.import_module(module.__name__) yield 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..e46b8e6385a --- /dev/null +++ b/src/confcom/azext_confcom/tests/latest/test_confcom_acifragmentgen.py @@ -0,0 +1,252 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# 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, 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")) + + +@pytest.fixture() +def docker_image(): + + registry_id = subprocess.run( + ["docker", "run", "-d", "-p", "0:5000", "registry:2"], + stdout=subprocess.PIPE, + text=True, + ).stdout + + registry_port = subprocess.run( + ["docker", "port", registry_id], + stdout=subprocess.PIPE, + text=True, + ).stdout.split(":")[-1].strip() + + 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({ + "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 + + subprocess.run(["docker", "stop", registry_id]) + + +@pytest.fixture(scope="session") +def cert_chain(): + with tempfile.TemporaryDirectory() 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() 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"), + out_signed_fragment=False, + ) + + # 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() 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"), + out_signed_fragment=False, + ) + + # 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() 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 + out_signed_fragment=False, + ) + + # Confirm the fragment exists and is attached in the registry + oras_result = json.loads(subprocess.run( + ["oras", "discover", image_ref, "--format", "json"], + stdout=subprocess.PIPE, + check=True, + ).stdout) + + 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["manifests"][0]["reference"] + else: + raise AssertionError(f"{oras_result=}") + + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", tempfile.gettempdir()], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + + 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, capsysbinary): + + image_ref, spec_file_path = docker_image + fragment_ref = image_ref.replace("hello-world", "fragment") + + 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 + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", tempfile.gettempdir()], + check=True, + 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, capsysbinary): + + image_ref, spec_file_path = docker_image + + 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 + oras_result = json.loads(subprocess.run( + ["oras", "discover", image_ref, "--format", "json"], + stdout=subprocess.PIPE, + check=True, + ).stdout) + + 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["manifests"][0]["reference"] + else: + raise AssertionError(f"{oras_result=}") + + fragment_path = json.loads(subprocess.run( + ["oras", "pull", fragment_ref, "--format", "json", "-o", tempfile.gettempdir()], + check=True, + stdout=subprocess.PIPE, + ).stdout)["files"][0]["path"] + + with open(fragment_path, "rb") as f: + assert f.read() == signed_fragment 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..9b764b58241 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,14 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import fcntl import os +import tempfile import unittest import json import deepdiff +import docker +import requests from unittest.mock import patch from azext_confcom.security_policy import ( @@ -24,6 +28,7 @@ ) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), "..")) +PRUNE_LOCK_PATH = f"{tempfile.gettempdir()}/confcom_docker_prune.lock" class PolicyGeneratingArm(unittest.TestCase): @@ -5007,7 +5012,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): 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..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 @@ -493,7 +494,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 +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"]) 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 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