Skip to content

Commit 90cfe9d

Browse files
[confcom] supporting OCI images and adding tests (#8570)
* adding fragment signing tests * fixing where params and vars are filled in Co-authored-by: Khalil Sayid <khalilsayid@microsoft.com> * updating docs to explain types of fragments Co-authored-by: Khalil Sayid <khalilsayid@microsoft.com> * fixing error that came up when using diff mode with non-default fragments * adding logging to some functions * adding support for OCI formatted images * adding exec process section to config file * adding support for custom mount options --------- Co-authored-by: Khalil Sayid <khalilsayid@microsoft.com>
1 parent b3e29f8 commit 90cfe9d

17 files changed

Lines changed: 519 additions & 162 deletions

src/confcom/HISTORY.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22
33
Release History
44
===============
5+
1.2.2
6+
++++++
7+
* support for pure OCI v1 schema 2 formatted images
8+
* adding debug logging
9+
* changing where parameters and variables are filled in for arm templates
10+
* updating documentation about fragments
11+
* bugfix for exec processes in fragment generation
12+
* bugfix for custom mount options in fragment generation
513

614
1.2.1
715
++++++

src/confcom/azext_confcom/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
- [allow_unencrypted_scratch](#allow_unencrypted_scratch)
3131
- [allow_capabilities_dropping](#allow_capabilities_dropping)
3232
- [Microsoft Azure CLI 'confcom acifragmentgen' Extension Examples](#microsoft-azure-cli-confcom-acifragmentgen-extension-examples)
33+
- [Types of Policy Fragments](#types-of-policy-fragments)
34+
- [Examples](#examples)
3335
- [Microsoft Azure CLI 'confcom katapolicygen' Extension Examples](#microsoft-azure-cli-confcom-katapolicygen-extension-examples)
3436

3537
## Microsoft Azure CLI 'confcom acipolicygen' Extension Examples
@@ -192,6 +194,8 @@ Use the following command to generate CCE policy for the image.
192194
az confcom acipolicygen -a .\sample-template-input.json --tar .\file.tar
193195
```
194196

197+
Note that multiple images saved to the tar file is only available using the docker-archive format for tar files. OCI does not support multi-image tar files at this time.
198+
195199
Example 12: If it is necessary to put images in their own tarballs, an external file can be used that maps images to their respective tarball paths. See the following example:
196200

197201
```bash
@@ -665,6 +669,15 @@ Run `az confcom acifragmentgen --help` to see a list of supported arguments alon
665669

666670
For information on what a policy fragment is, see [policy fragments](#policy-fragments). For a full walkthrough on how to generate a policy fragment and use it in a policy, see [Create a Key and Cert for Signing](../samples/certs/README.md).
667671

672+
### Types of Policy Fragments
673+
674+
There are two types of policy fragments:
675+
676+
1. Image-attached fragments: These are fragments that are attached to an image in an ORAS-compliant registry. They are used to provide additional security information about the image and are to be used for a single image. Image-attached fragments are currently in development. Note that nested image-attached fragments are *not* supported.
677+
2. Standalone fragments: These are fragments that are uploaded to an ORAS-compliant registry independent of a specific image and can be used for multiple images. Standalone fragments are currently not supported. Once implemented, nested standalone fragments will be supported.
678+
679+
### Examples
680+
668681
**Examples:**
669682

670683
Example 1: The following command creates a security fragment and prints it to stdout as well as saving it to a file `contoso.rego`:

src/confcom/azext_confcom/cose_proxy.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import platform
1010
from typing import List
1111
import requests
12+
from knack.log import get_logger
1213
from azext_confcom.errors import eprint
1314
from azext_confcom.config import (
1415
REGO_CONTAINER_START,
@@ -21,7 +22,7 @@
2122
ACI_FIELD_CONTAINERS_REGO_FRAGMENTS_INCLUDES,
2223
)
2324

24-
25+
logger = get_logger(__name__)
2526
host_os = platform.system()
2627
machine = platform.machine()
2728

@@ -128,7 +129,7 @@ def cose_sign(
128129

129130
if iss:
130131
arg_list.extend(["-issuer", iss])
131-
132+
logger.info("Signing the policy fragment: %s", out_path)
132133
call_cose_sign_tool(arg_list, "Error signing the policy fragment")
133134
return True
134135

@@ -150,7 +151,7 @@ def generate_import_from_path(self, fragment_path: str, minimum_svn: int) -> str
150151
policy_bin_str = str(self.policy_bin)
151152

152153
arg_list_chain = [policy_bin_str, "check", "--in", fragment_path, "--verbose"]
153-
154+
logger.info("Extracting import statement from signed fragment: %s", fragment_path)
154155
item = call_cose_sign_tool(arg_list_chain, "Error getting information from signed fragment file")
155156

156157
stdout = item.stdout.decode("utf-8")
@@ -182,7 +183,7 @@ def extract_payload_from_path(self, fragment_path: str) -> str:
182183
eprint(f"The fragment file at {fragment_path} does not exist")
183184

184185
arg_list_chain = [policy_bin_str, "check", "--in", fragment_path, "--verbose"]
185-
186+
logger.info("Extracting payload from signed fragment: %s", fragment_path)
186187
item = call_cose_sign_tool(arg_list_chain, "Error getting information from signed fragment file")
187188

188189
stdout = item.stdout.decode("utf-8")
@@ -194,7 +195,7 @@ def extract_feed_from_path(self, fragment_path: str) -> str:
194195
eprint(f"The fragment file at {fragment_path} does not exist")
195196

196197
arg_list_chain = [policy_bin_str, "check", "--in", fragment_path, "--verbose"]
197-
198+
logger.info("Extracting feed from signed fragment: %s", fragment_path)
198199
item = call_cose_sign_tool(arg_list_chain, "Error getting information from signed fragment file")
199200

200201
stdout = item.stdout.decode("utf-8")

src/confcom/azext_confcom/custom.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ def acipolicygen_confcom(
9797
# gather information about the fragments being used in the new policy
9898
if include_fragments:
9999
fragments_list = os_util.load_json_from_file(fragments_json or input_path)
100-
fragments_list = fragments_list.get("fragments", []) or fragments_list
100+
if isinstance(fragments_list, dict):
101+
fragments_list = fragments_list.get("fragments", [])
101102

102103
# convert to list if it's just a dict
103104
if not isinstance(fragments_list, list):
@@ -158,18 +159,21 @@ def acipolicygen_confcom(
158159
# and associate them with each container group
159160

160161
if include_fragments:
162+
logger.info("Including fragments in the policy")
161163
fragment_policy_list = []
162164
container_names = []
163165
fragment_imports = []
164166
for policy in container_group_policies:
165167
fragment_imports.extend(policy.get_fragments())
166168
for container in policy.get_images():
167169
container_names.append(container.get_container_image())
170+
# get all the fragments that are being used in the policy
168171
fragment_policy_list = get_all_fragment_contents(container_names, fragment_imports)
169172
for policy in container_group_policies:
170173
policy.set_fragment_contents(fragment_policy_list)
171174

172175
for count, policy in enumerate(container_group_policies):
176+
# this is where parameters and variables are populated
173177
policy.populate_policy_content_for_all_images(
174178
individual_image=bool(image_name), tar_mapping=tar_mapping, faster_hashing=faster_hashing
175179
)

src/confcom/azext_confcom/data/internal_config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "1.2.1",
2+
"version": "1.2.2",
33
"hcsshim_config": {
44
"maxVersion": "1.0.0",
55
"minVersion": "0.0.1"

src/confcom/azext_confcom/oras_proxy.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import json
88
import platform
99
import re
10+
from knack.log import get_logger
1011
from typing import List
1112
from azext_confcom.errors import eprint
1213
from azext_confcom.config import ARTIFACT_TYPE
@@ -16,6 +17,37 @@
1617
host_os = platform.system()
1718
machine = platform.machine()
1819

20+
logger = get_logger(__name__)
21+
22+
23+
def prepend_docker_registry(image_name: str) -> str:
24+
"""
25+
Normalize a Docker image reference by adding `docker.io/library` if necessary.
26+
27+
Args:
28+
image (str): The Docker image reference (e.g., `nginx:latest` or `myrepo/myimage`).
29+
30+
Returns:
31+
str: The normalized Docker image reference.
32+
"""
33+
# Split the image into name and tag
34+
if ":" in image_name:
35+
name, _ = image_name.rsplit(":", 1)
36+
else:
37+
name, _ = image_name, "latest"
38+
39+
registry = ""
40+
# Check if the image name contains a registry (e.g., docker.io, custom registry)
41+
if "/" not in name or "." not in name.split("/")[0]:
42+
# If no registry is specified, assume docker.io/library
43+
if "/" not in name:
44+
# Add the `library` namespace for official images
45+
registry = "library/"
46+
# Add the default `docker.io` registry
47+
registry = f"docker.io/{registry}"
48+
49+
return f"{registry}{image_name}"
50+
1951

2052
def call_oras_cli(args, check=False):
2153
return subprocess.run(args, check=check, capture_output=True, timeout=120)
@@ -26,10 +58,14 @@ def call_oras_cli(args, check=False):
2658
def discover(
2759
image: str,
2860
) -> List[str]:
61+
# normalize the name in case the docker registry is implied
62+
image = prepend_docker_registry(image)
63+
2964
arg_list = ["oras", "discover", image, "-o", "json", "--artifact-type", ARTIFACT_TYPE]
3065
item = call_oras_cli(arg_list, check=False)
3166
hashes = []
3267

68+
logger.info("Discovering fragments for %s: %s", image, item.stdout.decode('utf-8'))
3369
if item.returncode == 0:
3470
json_output = json.loads(item.stdout.decode("utf-8"))
3571
manifests = json_output.get("manifests", [])
@@ -55,6 +91,7 @@ def pull(
5591
if "@sha256:" in image:
5692
image = image.split("@")[0]
5793
arg_list = ["oras", "pull", f"{image}@{image_hash}"]
94+
logger.info("Pulling fragment: %s@%s", image, image_hash)
5895
item = call_oras_cli(arg_list, check=False)
5996

6097
# get the exit code from the subprocess

src/confcom/azext_confcom/os_util.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import shutil
1212
import json
1313
import os
14+
import stat
1415
from tarfile import TarFile
1516
from azext_confcom.errors import (
1617
eprint,
@@ -170,7 +171,7 @@ def map_image_from_tar_backwards_compatibility(image_name: str, tar: TarFile, ta
170171
][0]
171172
break
172173
# remove the extracted manifest file to clean up
173-
os.remove(manifest_path)
174+
force_delete_silently(manifest_path)
174175
else:
175176
eprint(f"Tarball at {tar_location} contains no images")
176177

@@ -182,14 +183,64 @@ def map_image_from_tar_backwards_compatibility(image_name: str, tar: TarFile, ta
182183
image_info_file_path = os.path.join(tar_dir, info_file.name)
183184
image_info_raw = load_json_from_file(image_info_file_path)
184185
# delete the extracted json file to clean up
185-
os.remove(image_info_file_path)
186+
force_delete_silently(image_info_file_path)
186187
image_info = image_info_raw.get("config")
187188
# importing the constant from config.py gives a circular dependency error
188189
image_info["Architecture"] = image_info_raw.get("architecture")
189190

191+
shutil.rmtree("blobs", ignore_errors=True)
190192
return image_info
191193

192194

195+
def get_oci_image_name(image_name: str) -> str:
196+
if "/" not in image_name:
197+
return f"docker.io/library/{image_name}"
198+
return image_name
199+
200+
201+
def read_file_from_tar(tar: TarFile, filename: str) -> str:
202+
try:
203+
return tar.extractfile(filename).read()
204+
except KeyError:
205+
eprint(f"'{filename}' not found in tar file")
206+
207+
208+
def map_image_from_tar_oci_layout_v1(image_name: str, tar: TarFile, tar_location: str):
209+
# since this uses containerd naming, we need to append the docker.io path
210+
oci_image_name = get_oci_image_name(image_name)
211+
212+
index_bytes = read_file_from_tar(tar, "index.json")
213+
index = load_json_from_str(index_bytes)
214+
215+
manifests = index.get("manifests") or []
216+
for manifest in manifests:
217+
image_annotations = manifest.get("annotations")
218+
image_name_annotation = ""
219+
if image_annotations:
220+
image_name_annotation = image_annotations.get("io.containerd.image.name")
221+
if image_name_annotation and image_name_annotation != oci_image_name:
222+
continue
223+
if (
224+
manifest.get("mediaType") in
225+
["application/vnd.docker.distribution.manifest.v2+json", "application/vnd.oci.image.manifest.v1+json"]
226+
):
227+
hashing_algo, manifest_name = manifest.get("digest").split(":")
228+
manifest_location = f"blobs/{hashing_algo}/{manifest_name}"
229+
230+
nested_manifest_bytes = tar.extractfile(manifest_location).read()
231+
nested_manifest = load_json_from_str(nested_manifest_bytes)
232+
config = nested_manifest.get("config")
233+
234+
config_hashing_algo, config_digest = config.get("digest").split(":")
235+
config_location = f"blobs/{config_hashing_algo}/{config_digest}"
236+
image_info_raw_bytes = tar.extractfile(config_location).read()
237+
image_info_raw = load_json_from_str(image_info_raw_bytes)
238+
image_info = image_info_raw.get("config")
239+
image_info["Architecture"] = image_info_raw.get("architecture")
240+
return image_info
241+
eprint(f"Image '{image_name}' is not found in '{tar_location}'")
242+
243+
193244
def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
194245
tar_dir = os.path.dirname(tar_location)
195246
info_file = None
@@ -209,7 +260,7 @@ def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
209260
break
210261
finally:
211262
# remove the extracted manifest file to clean up
212-
os.remove(manifest_path)
263+
force_delete_silently(manifest_path)
213264

214265
if not info_file:
215266
return None
@@ -219,14 +270,27 @@ def map_image_from_tar(image_name: str, tar: TarFile, tar_location: str):
219270
image_info_file_path = os.path.join(tar_dir, info_file)
220271
image_info_raw = load_json_from_file(image_info_file_path)
221272
# delete the extracted json file to clean up
222-
os.remove(image_info_file_path)
273+
force_delete_silently(image_info_file_path)
223274
image_info = image_info_raw.get("config")
224275
# importing the constant from config.py gives a circular dependency error
225276
image_info["Architecture"] = image_info_raw.get("architecture")
226277

227278
return image_info
228279

229280

281+
# sometimes image tarfiles have readonly members. this will try to change their permissions and delete them
282+
def force_delete_silently(filename: str) -> None:
283+
try:
284+
os.chmod(filename, stat.S_IWRITE)
285+
except FileNotFoundError:
286+
pass
287+
except PermissionError:
288+
eprint(f"Permission denied to edit file: {filename}")
289+
except OSError as e:
290+
eprint(f"Error editing file: {filename}, {e}")
291+
delete_silently(filename)
292+
293+
230294
# helper function to delete a file that may or may not exist
231295
def delete_silently(filename: str) -> None:
232296
try:

src/confcom/azext_confcom/rootfs_proxy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def get_policy_image_layers(
103103

104104
# decide if we're reading from a tarball or not
105105
if tar_location:
106+
logger.info("Calculating layer hashes from tarball")
106107
arg_list += ["--tarball", tar_location]
107108
else:
108109
arg_list += ["-d"]

0 commit comments

Comments
 (0)