Skip to content

Commit

Permalink
Feat: Add script for image index SBOM creation.
Browse files Browse the repository at this point in the history
  • Loading branch information
BorekZnovustvoritel committed Oct 16, 2024
1 parent ca08290 commit 2b4fe8d
Show file tree
Hide file tree
Showing 11 changed files with 609 additions and 2 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/build-sbom-utility-scripts-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ jobs:
cd ./sbom-utility-scripts/scripts/merge-cachi2-sboms-script/
tox
- name: Run tox checks for index-image-sbom-script
run: |
python3 -m pip install tox
cd ./sbom-utility-scripts/scripts/index-image-sbom-script/
tox
- name: Build Image
id: build-image
uses: redhat-actions/buildah-build@v2
Expand Down
6 changes: 4 additions & 2 deletions sbom-utility-scripts/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ COPY scripts/merge_syft_sboms.py /scripts
COPY scripts/merge-cachi2-sboms-script/merge_cachi2_sboms.py /scripts
COPY scripts/create_purl_sbom.py /scripts
COPY scripts/base-images-sbom-script/app/base_images_sbom_script.py /scripts
COPY scripts/base-images-sbom-script/app/requirements.txt /scripts
COPY scripts/base-images-sbom-script/app/requirements.txt /scripts/base-images-sbom-script-requirements.txt
COPY scripts/index-image-sbom-script/requirements.txt /scripts/index-image-sbom-script-requirements.txt
COPY scripts/index-image-sbom-script/index_image_sbom_script.py /scripts

RUN pip3 install -r requirements.txt
RUN pip3 install -r base-images-sbom-script-requirements.txt -r index-image-sbom-script-requirements.txt
41 changes: 41 additions & 0 deletions sbom-utility-scripts/scripts/index-image-sbom-script/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# SBOM for Image Index

This script builds SPDX2.3 SBOM for image index.

## Usage

**List of arguments:**

- `--image-index-url` / `-u`
- Must be in the format `repository/image:tag`
- Example value `quay.io/mkosiarc_rhtap/single-container-app:f2566ab`
- `--image-index-digest` / `-d`
- Must be in the format `algorithm:hexvalue`
- Example value `sha256:8f99627e843e931846855c5d899901bf093f5093e613a92745696a26b5420941`
- `--inspect-input-file` / `-i`
- Path to a file containing a json output of `buildah manifest inspect` command
- File contents MUST be a valid JSON
- See example in `sample_data/inspect.json`
- `--output-path` / `-o`
- Path where the SBOM should be written
- If omitted, SBOM is returned to STDOUT

## Behavior

This script creates an SBOM with externalRefs using both
PURLs from child digest and from index digest with `arch` qualifier.

## Example

To closely replicate the [example image index](https://github.com/RedHatProductSecurity/security-data-guidelines/blob/main/sbom/examples/container_image/build/ubi9-micro-container-9.4-6.1716471860.spdx.json),
you can use the following command:

```bash
python3 index_image_sbom_script.py \
-u registry.redhat.io/ubi-micro:9.4-6.1716471860 \
-d sha256:1c8483e0fda0e990175eb9855a5f15e0910d2038dd397d9e2b357630f0321e6d \
-i sample_data/inspect.json
```

To generate a different output, create a json document using `buildah manifest inspect <manifest_pullspec>`
and supply this file as the `-i` argument.
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Any
from dataclasses import dataclass
from uuid import uuid4

from packageurl import PackageURL


@dataclass
class Image:
repository: str
name: str
digest: str
tag: str
arch: Optional[str]

@staticmethod
def from_image_index_url_and_digest(
image_url_and_tag: str,
image_digest: str,
arch: Optional[str] = None,
) -> "Image":

repository, tag = image_url_and_tag.rsplit(":", 1)
_, name = repository.rsplit("/", 1)
return Image(
repository=repository,
name=name,
digest=image_digest,
tag=tag,
arch=arch,
)

@property
def digest_algo(self) -> str:
algo, _ = self.digest.split(":")
return algo.upper()

@property
def digest_hex_val(self) -> str:
_, val = self.digest.split(":")
return val

def purls(self, index_digest: Optional[str] = None) -> list[str]:
ans = []
if index_digest and self.arch:
ans.append(
PackageURL(
type="oci",
name=self.name,
version=index_digest,
qualifiers={"arch": self.arch, "repository_url": self.repository},
).to_string()
)
ans.append(
PackageURL(
type="oci", name=self.name, version=self.digest, qualifiers={"repository_url": self.repository}
).to_string()
)
return ans

def propose_spdx_id(self) -> str:
purl_hex_digest = hashlib.sha256(self.purls()[0].encode()).hexdigest()
return f"SPDXRef-image-{self.name}-{purl_hex_digest}"


def create_package(image: Image, spdxid: Optional[str] = None, image_index_digest: Optional[str] = None) -> dict:
return {
"SPDXID": image.propose_spdx_id() if not spdxid else spdxid,
"name": image.name if not image.arch else f"{image.name}_{image.arch}",
"versionInfo": image.tag,
"supplier": "NOASSERTION",
"downloadLocation": "NOASSERTION",
"licenseDeclared": "NOASSERTION",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": purl,
}
for purl in image.purls(image_index_digest)
],
"checksums": [
{
"algorithm": image.digest_algo,
"checksumValue": image.digest_hex_val,
}
],
}


def get_relationship(spdxid: str, related_spdxid: str):
return {
"spdxElementId": spdxid,
"relationshipType": "VARIANT_OF",
"relatedSpdxElement": related_spdxid,
}


def create_sbom(
image_index_url: str,
image_index_digest: str,
inspect_input: dict[str, Any],
) -> dict:
if inspect_input["mediaType"] != "application/vnd.oci.image.index.v1+json":
raise ValueError("Invalid input file detected, requires `buildah manifest inspect` json.")

image_index_obj = Image.from_image_index_url_and_digest(image_index_url, image_index_digest)
sbom_name = f"{image_index_obj.name}-{image_index_obj.tag}"

packages = [create_package(image_index_obj, "SPDXRef-image-index")]
relationships = [
{
"spdxElementId": "SPDXRef-DOCUMENT",
"relationshipType": "DESCRIBES",
"relatedSpdxElement": "SPDXRef-image-index",
}
]

for manifest in inspect_input["manifests"]:
if manifest["mediaType"] != "application/vnd.oci.image.manifest.v1+json":
continue

arch_image = Image(
arch=manifest.get("platform", {}).get("architecture"),
name=image_index_obj.name,
digest=manifest.get("digest"),
tag=image_index_obj.tag,
repository=image_index_obj.repository,
)
packages.append(create_package(arch_image, image_index_digest=image_index_obj.digest))
relationships.append(get_relationship(arch_image.propose_spdx_id(), "SPDXRef-image-index"))

sbom = {
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"documentNamespace": f"https://konflux-ci.dev/spdxdocs/{sbom_name}-{uuid4()}",
"SPDXID": "SPDXRef-DOCUMENT",
"creationInfo": {
"created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"creators": ["Tool: Konflux"],
"licenseListVersion": "3.25",
},
"name": sbom_name,
"packages": packages,
"relationships": relationships,
}
return sbom


def main():
parser = argparse.ArgumentParser(description="Create an image index SBOM.")
parser.add_argument(
"--image-index-url",
"-u",
type=str,
help="Image index URL in the format 'repository/image:tag'.",
required=True,
)
parser.add_argument(
"--image-index-digest",
"-d",
type=str,
help="Image index digest in the format 'algorithm:digest'.",
required=True,
)
parser.add_argument(
"--inspect-input-file",
"-i",
type=Path,
help="Inspect json file produced by image index inspection.",
required=True,
)
parser.add_argument(
"--output-path",
"-o",
type=str,
help="Path to save the output SBOM in JSON format.",
)
args = parser.parse_args()
with open(args.inspect_input_file, "r") as inp_file:
inspect_input = json.load(inp_file)

sbom = create_sbom(args.image_index_url, args.image_index_digest, inspect_input)
if args.output_path:
with open(args.output_path, "w") as fp:
json.dump(sbom, fp, indent=4)
else:
print(json.dumps(sbom, indent=4))


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pytest
pytest-mock
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile requirements-test.in
#
iniconfig==2.0.0
# via pytest
packaging==24.1
# via pytest
pluggy==1.5.0
# via pytest
pytest==8.3.3
# via
# -r requirements-test.in
# pytest-mock
pytest-mock==3.14.0
# via -r requirements-test.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
packageurl-python==0.15.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile requirements.in
#
packageurl-python==0.15.0
# via -r requirements.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:f08722139c4da653b870272a192fac700960a3315baa1f79f83a4712a436d4",
"size": 201,
"platform": {
"architecture": "ppc64le",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:c9e70f4174747c6b53d253e879177c52731cc4bdc5fe9c6a2555412d849a952",
"size": 201,
"platform": {
"architecture": "s390x",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:629211996680a3143cd42dc99e19ad2dbb2c3c8a4fa9568607e9d96da8deaf01",
"size": 201,
"platform": {
"architecture": "arm64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:13fd2a0116a76eaa274fee20c86eef4dfba9f311784e8fb7d7f5fc38b32f3ef",
"size": 201,
"platform": {
"architecture": "amd64",
"os": "linux"
}
}
]
}
Loading

0 comments on commit 2b4fe8d

Please sign in to comment.