1080 lines
33 KiB
Python
1080 lines
33 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2023 Google LLC. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Utility for handling SBOM files."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import hashlib
|
|
import json
|
|
import random
|
|
import re
|
|
|
|
from apitools.base.py import encoding
|
|
from apitools.base.py import exceptions as apitools_exceptions
|
|
from containerregistry.client import docker_creds
|
|
from containerregistry.client import docker_name
|
|
from containerregistry.client.v2_2 import docker_http as v2_2_docker_http
|
|
from containerregistry.client.v2_2 import docker_image as v2_2_image
|
|
from containerregistry.client.v2_2 import docker_image_list as v2_2_image_list
|
|
from googlecloudsdk.api_lib.artifacts import exceptions as ar_exceptions
|
|
from googlecloudsdk.api_lib.cloudkms import base as cloudkms_base
|
|
from googlecloudsdk.api_lib.container.images import util as gcr_util
|
|
from googlecloudsdk.api_lib.containeranalysis import filter_util
|
|
from googlecloudsdk.api_lib.containeranalysis import requests as ca_requests
|
|
from googlecloudsdk.api_lib.storage import storage_api
|
|
from googlecloudsdk.api_lib.storage import storage_util
|
|
from googlecloudsdk.command_lib.artifacts import docker_util
|
|
from googlecloudsdk.command_lib.artifacts import requests as ar_requests
|
|
from googlecloudsdk.command_lib.artifacts import util
|
|
from googlecloudsdk.command_lib.projects import util as project_util
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import resources
|
|
from googlecloudsdk.core import transports
|
|
from googlecloudsdk.core.util import files
|
|
import requests
|
|
import six
|
|
from six.moves import urllib
|
|
|
|
_SBOM_FORMAT_SPDX = 'spdx'
|
|
_SBOM_FORMAT_CYCLONEDX = 'cyclonedx'
|
|
_UNSUPPORTED_SBOM_FORMAT_ERROR = (
|
|
'The file is not in a supported SBOM format. '
|
|
+ 'Only spdx and cyclonedx are supported.'
|
|
)
|
|
|
|
_SBOM_REFERENCE_PAYLOAD_TYPE = 'application/vnd.in-toto+json'
|
|
_SBOM_REFERENCE_TARGET_TYPE = 'https://in-toto.io/Statement/v0.1'
|
|
_SBOM_REFERENCE_PREDICATE_TYPE = (
|
|
'https://bcid.corp' + '.google.com/reference/v0.1'
|
|
)
|
|
_SBOM_REFERENCE_SPDX_MIME_TYPE = 'application/spdx+json'
|
|
_SBOM_REFERENCE_DEFAULT_MIME_TYPE = 'application/json'
|
|
_SBOM_REFERENCE_CYCLONEDX_MIME_TYPE = 'application/vnd.cyclonedx+json'
|
|
_SBOM_REFERENCE_REFERRERID = (
|
|
'https://containeranalysis.googleapis.com/ArtifactAnalysis@v0.1'
|
|
)
|
|
|
|
_SBOM_REFERENCE_SPDX_EXTENSION = 'spdx.json'
|
|
_SBOM_REFERENCE_DEFAULT_EXTENSION = 'json'
|
|
_SBOM_REFERENCE_CYCLONEDX_EXTENSION = 'bom.json'
|
|
|
|
_BUCKET_NAME_CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
|
_BUCKET_SUFFIX_LENGTH = 5
|
|
|
|
_DEFAULT_DOCKER_REGISTRY = 'registry.hub.docker.com'
|
|
_DEFAULT_DOCKER_REPOSITORY = 'library'
|
|
|
|
_REGISTRY_SCHEME_HTTP = 'http'
|
|
_REGISTRY_SCHEME_HTTPS = 'https'
|
|
|
|
ARTIFACT_TYPE_AR_IMAGE = 'artifactregistry'
|
|
ARTIFACT_TYPE_GCR_IMAGE = 'gcr'
|
|
ARTIFACT_TYPE_OTHER = 'other'
|
|
|
|
|
|
def _ParseSpdx(data):
|
|
"""Retrieves version from the given SBOM dict.
|
|
|
|
Args:
|
|
data: Parsed json content of an SBOM file.
|
|
|
|
Raises:
|
|
ar_exceptions.InvalidInputValueError: If the sbom format is not supported.
|
|
|
|
Returns:
|
|
A SbomFile object with metadata of the given sbom.
|
|
"""
|
|
spdx_version = data['spdxVersion']
|
|
version = None
|
|
if isinstance(spdx_version, six.string_types):
|
|
r = re.match(r'^SPDX-([0-9]+[.][0-9]+)$', spdx_version)
|
|
if r is not None:
|
|
version = r.group(1)
|
|
if not version:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Unable to read spdxVersion {0}.'.format(spdx_version)
|
|
)
|
|
|
|
return SbomFile(sbom_format=_SBOM_FORMAT_SPDX, version=version)
|
|
|
|
|
|
def _ParseCycloneDx(data):
|
|
"""Retrieves version from the given SBOM dict.
|
|
|
|
Args:
|
|
data: Parsed json content of an SBOM file.
|
|
|
|
Raises:
|
|
ar_exceptions.InvalidInputValueError: If the sbom format is not supported.
|
|
|
|
Returns:
|
|
A SbomFile object with metadata of the given sbom.
|
|
"""
|
|
if 'specVersion' not in data:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Unable to find specVersion in the CycloneDX file.'
|
|
)
|
|
|
|
version = None
|
|
if isinstance(data['specVersion'], six.string_types):
|
|
r = re.match(r'^[0-9]+[.][0-9]+$', data['specVersion'])
|
|
if r is not None:
|
|
version = r.group()
|
|
if not version:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Unable to read specVersion {0}.'.format(data['specVersion'].__str__())
|
|
)
|
|
|
|
return SbomFile(sbom_format=_SBOM_FORMAT_CYCLONEDX, version=version)
|
|
|
|
|
|
def ParseJsonSbom(file_path):
|
|
"""Retrieves information about a docker image based on the fully-qualified name.
|
|
|
|
Args:
|
|
file_path: str, The sbom file location.
|
|
|
|
Raises:
|
|
ar_exceptions.InvalidInputValueError: If the sbom format is not supported.
|
|
|
|
Returns:
|
|
An SbomFile object with metadata of the given sbom.
|
|
"""
|
|
|
|
try:
|
|
content = files.ReadFileContents(file_path)
|
|
data = json.loads(content)
|
|
except ValueError as e:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'The file is not a valid JSON file', e
|
|
)
|
|
except files.Error as e:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Failed to read the sbom file', e
|
|
)
|
|
# Detect if it's spdx or cyclonedx.
|
|
if 'spdxVersion' in data:
|
|
res = _ParseSpdx(data)
|
|
elif data.get('bomFormat') == 'CycloneDX':
|
|
res = _ParseCycloneDx(data)
|
|
else:
|
|
raise ar_exceptions.InvalidInputValueError(_UNSUPPORTED_SBOM_FORMAT_ERROR)
|
|
sha256_digest = hashlib.sha256(six.ensure_binary(content)).hexdigest()
|
|
res.digests['sha256'] = sha256_digest
|
|
return res
|
|
|
|
|
|
def _GetARDockerImage(uri):
|
|
"""Retrieves metadata from the given AR docker image.
|
|
|
|
Args:
|
|
uri: Uri of the AR docker image.
|
|
|
|
Raises:
|
|
ar_exceptions.InvalidInputValueError: If the uri is invalid.
|
|
|
|
Returns:
|
|
An Artifact object with metadata of the given artifact.
|
|
"""
|
|
|
|
image, docker_version = docker_util.DockerUrlToVersion(uri)
|
|
repo = image.docker_repo
|
|
digests = {'sha256': docker_version.digest.replace('sha256:', '')}
|
|
return Artifact(
|
|
resource_uri=docker_version.GetDockerString(),
|
|
project=repo.project,
|
|
location=repo.location,
|
|
digests=digests,
|
|
artifact_type=ARTIFACT_TYPE_AR_IMAGE,
|
|
scheme=_REGISTRY_SCHEME_HTTPS,
|
|
)
|
|
|
|
|
|
def _GetGCRImage(uri):
|
|
"""Retrieves information about the given GCR image.
|
|
|
|
Args:
|
|
uri: str, The artifact uri.
|
|
|
|
Raises:
|
|
ar_exceptions.InvalidInputValueError: If the uri is invalid.
|
|
|
|
Returns:
|
|
An Artifact object with metadata of the given artifact.
|
|
"""
|
|
location_map = {
|
|
'us.gcr.io': 'us',
|
|
'gcr.io': 'us',
|
|
'eu.gcr.io': 'europe',
|
|
'asia.gcr.io': 'asia',
|
|
}
|
|
# Get digest by using image.
|
|
try:
|
|
docker_digest = gcr_util.GetDigestFromName(uri)
|
|
except gcr_util.InvalidImageNameError as e:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Failed to resolve digest of the GCR image: {}'.format(e)
|
|
)
|
|
project = None
|
|
location = None
|
|
matches = re.match(docker_util.GCR_DOCKER_REPO_REGEX, uri)
|
|
if matches:
|
|
location = location_map[matches.group('repo')]
|
|
project = matches.group('project')
|
|
matches = re.match(docker_util.GCR_DOCKER_DOMAIN_SCOPED_REPO_REGEX, uri)
|
|
if matches:
|
|
location = location_map[matches.group('repo')]
|
|
project = matches.group('project').replace('/', ':', 1)
|
|
if not project or not location:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Failed to parse project and location from the GCR image.'
|
|
)
|
|
return Artifact(
|
|
resource_uri=docker_digest.__str__(),
|
|
project=project,
|
|
location=location,
|
|
digests={'sha256': docker_digest.digest.replace('sha256:', '')},
|
|
artifact_type=ARTIFACT_TYPE_GCR_IMAGE,
|
|
scheme=_REGISTRY_SCHEME_HTTPS,
|
|
)
|
|
|
|
|
|
def _ResolveDockerImageDigest(image):
|
|
"""Returns Digest of the given Docker image.
|
|
|
|
Lookup registry to get the manifest's digest. If it returns a list of
|
|
manifests, will return the first one.
|
|
|
|
Args:
|
|
image: docker_name.Tag or docker_name.Digest, Docker image.
|
|
|
|
Returns:
|
|
An str for the digest.
|
|
"""
|
|
with v2_2_image_list.FromRegistry(
|
|
basic_creds=docker_creds.Anonymous(),
|
|
name=image,
|
|
transport=transports.GetApitoolsTransport(),
|
|
) as manifest_list:
|
|
if manifest_list.exists():
|
|
return manifest_list.digest()
|
|
with v2_2_image.FromRegistry(
|
|
basic_creds=docker_creds.Anonymous(),
|
|
name=image,
|
|
transport=transports.GetApitoolsTransport(),
|
|
accepted_mimes=v2_2_docker_http.SUPPORTED_MANIFEST_MIMES,
|
|
) as v2_2_img:
|
|
if v2_2_img.exists():
|
|
return v2_2_img.digest()
|
|
|
|
return None
|
|
|
|
|
|
def _GetDockerImage(uri):
|
|
"""Retrieves information about the given docker image.
|
|
|
|
Args:
|
|
uri: str, The artifact uri.
|
|
|
|
Raises:
|
|
ar_exceptions.InvalidInputValueError: If the artifact is with tag, and it
|
|
can not be resolved by querying the docker http APIs.
|
|
|
|
Returns:
|
|
An Artifact object with metadata of the given artifact.
|
|
"""
|
|
try:
|
|
image_digest = docker_name.from_string(uri)
|
|
if isinstance(image_digest, docker_name.Digest):
|
|
return Artifact(
|
|
resource_uri=uri,
|
|
digests={'sha256': image_digest.digest.replace('sha256:', '')},
|
|
artifact_type=ARTIFACT_TYPE_OTHER,
|
|
project=None,
|
|
location=None,
|
|
scheme=None,
|
|
)
|
|
except (docker_name.BadNameException,) as e:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Failed to resolve {0}: {1}'.format(uri, str(e))
|
|
)
|
|
|
|
image_uri = uri
|
|
if ':' not in uri:
|
|
image_uri = uri + ':latest'
|
|
image_tag = docker_name.Tag(name=image_uri)
|
|
scheme = v2_2_docker_http.Scheme(image_tag.registry)
|
|
try:
|
|
digest = _ResolveDockerImageDigest(image_tag)
|
|
except (
|
|
v2_2_docker_http.V2DiagnosticException,
|
|
requests.exceptions.InvalidURL,
|
|
v2_2_docker_http.BadStateException,
|
|
) as e:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Failed to resolve {0}: {1}'.format(uri, str(e))
|
|
)
|
|
if not digest:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Failed to resolve {0}.'.format(uri)
|
|
)
|
|
resource_uri = '{registry}/{repo}@{digest}'.format(
|
|
registry=image_tag.registry, repo=image_tag.repository, digest=digest
|
|
)
|
|
return Artifact(
|
|
resource_uri=resource_uri,
|
|
digests={'sha256': digest.replace('sha256:', '')},
|
|
artifact_type=ARTIFACT_TYPE_OTHER,
|
|
project=None,
|
|
location=None,
|
|
scheme=scheme,
|
|
)
|
|
|
|
|
|
def ProcessArtifact(uri):
|
|
"""Retrieves information about the given artifact.
|
|
|
|
Args:
|
|
uri: str, The artifact uri.
|
|
|
|
Raises:
|
|
ar_exceptions.InvalidInputValueError: If the artifact type is unsupported.
|
|
|
|
Returns:
|
|
An Artifact object with metadata of the given artifact.
|
|
"""
|
|
|
|
if docker_util.IsARDockerImage(uri):
|
|
return _GetARDockerImage(uri)
|
|
elif docker_util.IsGCRImage(uri):
|
|
return _GetGCRImage(uri)
|
|
else:
|
|
# Handle as normal docker containers
|
|
try:
|
|
return _GetDockerImage(uri)
|
|
except ar_exceptions.InvalidInputValueError as e:
|
|
log.debug('Failed to resolve the artifact: {}'.format(e))
|
|
return Artifact(
|
|
resource_uri=uri,
|
|
digests={},
|
|
artifact_type=ARTIFACT_TYPE_OTHER,
|
|
project=None,
|
|
location=None,
|
|
scheme=None,
|
|
)
|
|
|
|
|
|
def _RemovePrefix(value, prefix):
|
|
if value.startswith(prefix):
|
|
return value[len(prefix) :]
|
|
return value
|
|
|
|
|
|
def _EnsurePrefix(value, prefix):
|
|
if not value.startswith(prefix):
|
|
value = prefix + value
|
|
return value
|
|
|
|
|
|
def ListSbomReferences(args):
|
|
"""Lists SBOM references in a given project.
|
|
|
|
Args:
|
|
args: User input arguments.
|
|
|
|
Returns:
|
|
List of SBOM references.
|
|
"""
|
|
resource = args.resource
|
|
prefix = args.resource_prefix
|
|
dependency = args.dependency
|
|
|
|
if (resource and (prefix or dependency)) or (prefix and dependency):
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'Cannot specify more than one of the flags --dependency,'
|
|
' --resource and --resource-prefix.'
|
|
)
|
|
|
|
filters = filter_util.ContainerAnalysisFilter().WithKinds(['SBOM_REFERENCE'])
|
|
project = util.GetProject(args)
|
|
|
|
if dependency:
|
|
dependency_filters = (
|
|
filter_util.ContainerAnalysisFilter()
|
|
.WithKinds(['PACKAGE'])
|
|
.WithCustomFilter(
|
|
'noteProjectId="goog-analysis" AND dependencyPackageName="{}"'
|
|
.format(dependency)
|
|
)
|
|
)
|
|
|
|
package_occs = list(
|
|
ca_requests.ListOccurrences(
|
|
project, dependency_filters.GetFilter(), None
|
|
)
|
|
)
|
|
if not package_occs:
|
|
return []
|
|
|
|
# Deduplicate image uris, since one image may have multiple package
|
|
# dependencies with the same name but different versions.
|
|
# All AA generated SBOM occurrence resource uris start with 'https://'.
|
|
images = set(_EnsurePrefix(o.resourceUri, 'https://') for o in package_occs)
|
|
filters.WithResources(images)
|
|
|
|
if resource:
|
|
resource_uri = _RemovePrefix(resource, 'https://')
|
|
# We want to match the input if the user stores it as uri.
|
|
resource_uris = [
|
|
'https://{}'.format(resource_uri),
|
|
resource_uri,
|
|
]
|
|
try:
|
|
# Verify image uri and resolve possible tags.
|
|
artifact = ProcessArtifact(resource_uri)
|
|
if resource_uri != artifact.resource_uri:
|
|
# Match the resolved uri if it's different.
|
|
resource_uris = resource_uris + [
|
|
'https://{}'.format(artifact.resource_uri),
|
|
artifact.resource_uri,
|
|
]
|
|
# Update the project for the request when a specific resource is provided.
|
|
if artifact.project:
|
|
project = util.GetParent(artifact.project, args.location)
|
|
|
|
except (ar_exceptions.InvalidInputValueError, docker_name.BadNameException):
|
|
# Failed to process the artifact. Use the uri directly
|
|
log.status.Print(
|
|
'Failed to resolve the artifact. Filter on the URI directly.'
|
|
)
|
|
pass
|
|
|
|
filters.WithResources(resource_uris)
|
|
|
|
if prefix:
|
|
path_prefix = _RemovePrefix(prefix, 'https://')
|
|
filters.WithResourcePrefixes([
|
|
'https://{}'.format(path_prefix),
|
|
path_prefix,
|
|
])
|
|
|
|
if dependency:
|
|
# Calling ListOccurrencesWithFilters ignoring page_size.
|
|
occs = ca_requests.ListOccurrencesWithFilters(
|
|
project, filters.GetChunkifiedFilters()
|
|
)
|
|
else:
|
|
occs = ca_requests.ListOccurrences(
|
|
project, filters.GetFilter(), args.page_size
|
|
)
|
|
|
|
# Verifying GCS can be slow for large amount of occurrences, so we decided to
|
|
# only verify it when `resource` is provided.
|
|
if resource:
|
|
return _VerifyGCSObjects(occs)
|
|
return [SbomReference(occ, {}) for occ in occs]
|
|
|
|
|
|
def _VerifyGCSObjects(occs):
|
|
return [_VerifyGCSObject(occ) for occ in occs]
|
|
|
|
|
|
def _VerifyGCSObject(occ):
|
|
"""Verify the existence and the content of a GCS SBOM file object.
|
|
|
|
Args:
|
|
occ: SBOM reference occurrence.
|
|
|
|
Returns:
|
|
An SbomReference object with the input occurrence and SBOM file information.
|
|
"""
|
|
gcs_client = storage_api.StorageClient()
|
|
obj_ref = storage_util.ObjectReference.FromUrl(
|
|
occ.sbomReference.payload.predicate.location
|
|
)
|
|
|
|
file_info = {}
|
|
try:
|
|
gcs_client.GetObject(obj_ref)
|
|
except apitools_exceptions.HttpNotFoundError:
|
|
file_info['exists'] = False
|
|
except apitools_exceptions.HttpError as e:
|
|
msg = json.loads(e.content)
|
|
file_info['err_msg'] = msg['error']['message']
|
|
except Exception as e: # pylint: disable=broad-except
|
|
# Catch everything here since we don't need or want it to block the output.
|
|
# Simply copy the error message into the file information.
|
|
file_info['err_msg'] = str(e)
|
|
else:
|
|
file_info['exists'] = True
|
|
|
|
# TODO(b/271564503): Verify SBOM file content and set file_info['verified'].
|
|
|
|
return SbomReference(occ, file_info)
|
|
|
|
|
|
def _DefaultGCSBucketName(project_num, location):
|
|
return 'artifactanalysis-{0}-{1}'.format(location, project_num)
|
|
|
|
|
|
def _GetSbomGCSPath(storage_path, resource_uri, sbom):
|
|
uri_encoded = urllib.parse.urlencode({'uri': resource_uri})[4:]
|
|
version = sbom.version.replace('.', '-')
|
|
return ('gs://{storage_path}/{uri_encoded}/sbom/user-{version}.{ext}').format(
|
|
**{
|
|
'storage_path': storage_path.replace('gs://', '').rstrip('/'),
|
|
'uri_encoded': uri_encoded,
|
|
'version': version,
|
|
'ext': sbom.GetExtension(),
|
|
}
|
|
)
|
|
|
|
|
|
def _FindAvailableGCSBucket(default_bucket, project_id, location):
|
|
"""Find an appropriate default bucket to store the SBOM file.
|
|
|
|
Find a bucket with the same prefix same as the default bucket in the project.
|
|
If no bucket could be found, will start to create a new bucket by
|
|
concatenating the default bucket name and a random suffix.
|
|
|
|
Args:
|
|
default_bucket: str, targeting default bucket name for the resource.
|
|
project_id: str, project we will use to store the SBOM.
|
|
location: str, location we will use to store the SBOM.
|
|
|
|
Returns:
|
|
bucket_name: str, name of the prepared bucket.
|
|
"""
|
|
gcs_client = storage_api.StorageClient()
|
|
buckets = gcs_client.ListBuckets(project=project_id)
|
|
for bucket in buckets:
|
|
log.debug('Verifying bucket {}'.format(bucket.name))
|
|
if not bucket.name.startswith(default_bucket):
|
|
continue
|
|
if bucket.locationType.lower() == 'dual-region':
|
|
log.debug('Skipping dual region bucket {}'.format(bucket.name))
|
|
continue
|
|
if bucket.location.lower() != location.lower():
|
|
log.debug(
|
|
'The bucket {0} has location {1} is not matching {2}.'.format(
|
|
bucket.name, bucket.location.lower(), location.lower()
|
|
)
|
|
)
|
|
continue
|
|
return bucket.name
|
|
# Failed to find a existing bucket to use.
|
|
# Create a new backup bucket with a random suffix.
|
|
bucket_name = default_bucket + '-'
|
|
for _ in range(_BUCKET_SUFFIX_LENGTH):
|
|
bucket_name = bucket_name + random.choice(_BUCKET_NAME_CHARS)
|
|
gcs_client.CreateBucketIfNotExists(
|
|
bucket=bucket_name,
|
|
project=project_id,
|
|
location=location,
|
|
check_ownership=True,
|
|
enable_uniform_level_access=True,
|
|
)
|
|
try:
|
|
bucket = gcs_client.GetBucket(bucket=bucket_name)
|
|
labels_dict = encoding.MessageToDict(bucket.labels) if bucket.labels else {}
|
|
labels_dict['goog-managed-by'] = 'artifact-analysis'
|
|
bucket.labels = encoding.DictToMessage(
|
|
labels_dict, gcs_client.messages.Bucket.LabelsValue
|
|
)
|
|
request = gcs_client.messages.StorageBucketsPatchRequest(
|
|
bucket=bucket_name,
|
|
bucketResource=bucket,
|
|
)
|
|
gcs_client.client.buckets.Patch(request)
|
|
# pylint: disable=broad-exception-caught
|
|
except Exception as e:
|
|
log.warning('Failed to add labels to bucket {}: {}'.format(bucket_name, e))
|
|
return bucket_name
|
|
|
|
|
|
def UploadSbomToGCS(source, artifact, sbom, gcs_path=None):
|
|
"""Upload an SBOM file onto the GCS bucket in the given project and location.
|
|
|
|
Args:
|
|
source: str, the SBOM file location.
|
|
artifact: Artifact, the artifact metadata SBOM file generated from.
|
|
sbom: SbomFile, metadata of the SBOM file.
|
|
gcs_path: str, the GCS location for the SBOm file. If not provided, will use
|
|
the default bucket path of the artifact.
|
|
|
|
Returns:
|
|
dest: str, the GCS storage path the file is copied to.
|
|
"""
|
|
gcs_client = storage_api.StorageClient()
|
|
|
|
if gcs_path:
|
|
dest = _GetSbomGCSPath(gcs_path, artifact.resource_uri, sbom)
|
|
else:
|
|
project_num = project_util.GetProjectNumber(artifact.project)
|
|
bucket_project = artifact.project
|
|
# Make sure we use eu in all bucket queries to match the naming of GCS.
|
|
bucket_location = artifact.location
|
|
if bucket_location == 'europe':
|
|
bucket_location = 'eu'
|
|
default_bucket = _DefaultGCSBucketName(project_num, bucket_location)
|
|
|
|
bucket_name = default_bucket
|
|
use_backup_bucket = False
|
|
try:
|
|
# Make sure the bucket exists, and it's in the right project.
|
|
gcs_client.CreateBucketIfNotExists(
|
|
bucket=bucket_name,
|
|
project=bucket_project,
|
|
location=bucket_location,
|
|
check_ownership=True,
|
|
)
|
|
try:
|
|
bucket = gcs_client.GetBucket(bucket=bucket_name)
|
|
labels_dict = (
|
|
encoding.MessageToDict(bucket.labels) if bucket.labels else {}
|
|
)
|
|
labels_dict['goog-managed-by'] = 'artifact-analysis'
|
|
bucket.labels = encoding.DictToMessage(
|
|
labels_dict, gcs_client.messages.Bucket.LabelsValue
|
|
)
|
|
request = gcs_client.messages.StorageBucketsPatchRequest(
|
|
bucket=bucket_name,
|
|
bucketResource=bucket,
|
|
)
|
|
gcs_client.client.buckets.Patch(request)
|
|
# pylint: disable=broad-exception-caught
|
|
except Exception as e:
|
|
log.warning(
|
|
'Failed to add labels to bucket {}: {}'.format(bucket_name, e)
|
|
)
|
|
except storage_api.BucketInWrongProjectError:
|
|
# User is given permission to get and use the bucket, but the bucket is
|
|
# not in the correct project. Will fallback to find a backup bucket.
|
|
log.debug('The default bucket is in a wrong project.')
|
|
use_backup_bucket = True
|
|
except apitools_exceptions.HttpForbiddenError:
|
|
# Either user is not having the permission to get the bucket, or the
|
|
# bucket is created by other users in a different project. We will try to
|
|
# see if we can find a backup bucket to use.
|
|
log.debug('The default bucket cannot be accessed.')
|
|
use_backup_bucket = True
|
|
if use_backup_bucket:
|
|
bucket_name = _FindAvailableGCSBucket(
|
|
default_bucket, bucket_project, bucket_location
|
|
)
|
|
|
|
log.debug('Using bucket: {}'.format(bucket_name))
|
|
dest = _GetSbomGCSPath(bucket_name, artifact.resource_uri, sbom)
|
|
|
|
target_ref = storage_util.ObjectReference.FromUrl(dest)
|
|
gcs_client.CopyFileToGCS(source, target_ref)
|
|
|
|
return dest
|
|
|
|
|
|
def _CreateSbomRefNoteIfNotExists(project_id, sbom):
|
|
"""Create the SBOM reference note if not exists.
|
|
|
|
Args:
|
|
project_id: str, the project we will use to create the note.
|
|
sbom: SbomFile, metadata of the SBOM file.
|
|
|
|
Returns:
|
|
A Note object for the targeting SBOM reference note.
|
|
"""
|
|
client = ca_requests.GetClient()
|
|
messages = ca_requests.GetMessages()
|
|
|
|
note_id = _GetReferenceNoteID(sbom.sbom_format, sbom.version)
|
|
name = resources.REGISTRY.Create(
|
|
collection='containeranalysis.projects.notes',
|
|
projectsId=project_id,
|
|
notesId=note_id,
|
|
).RelativeName()
|
|
|
|
try:
|
|
get_request = messages.ContaineranalysisProjectsNotesGetRequest(name=name)
|
|
note = client.projects_notes.Get(get_request)
|
|
except apitools_exceptions.HttpNotFoundError:
|
|
log.debug('Note not found. Creating note {0}.'.format(name))
|
|
sbom_reference = messages.SBOMReferenceNote(
|
|
format=sbom.sbom_format, version=sbom.version
|
|
)
|
|
new_note = messages.Note(
|
|
kind=messages.Note.KindValueValuesEnum.SBOM_REFERENCE,
|
|
sbomReference=sbom_reference,
|
|
)
|
|
create_request = messages.ContaineranalysisProjectsNotesCreateRequest(
|
|
parent='projects/{project}'.format(project=project_id),
|
|
noteId=note_id,
|
|
note=new_note,
|
|
)
|
|
note = client.projects_notes.Create(create_request)
|
|
|
|
log.debug('get note results: {0}'.format(note))
|
|
return note
|
|
|
|
|
|
def _GenerateSbomRefOccurrence(artifact, sbom, note, storage):
|
|
"""Create the SBOM reference note if not exists.
|
|
|
|
Args:
|
|
artifact: Artifact, the artifact metadata SBOM file generated from.
|
|
sbom: SbomFile, metadata of the SBOM file.
|
|
note: Note, the Note object we will use to attach occurrence.
|
|
storage: str, the path that SBOM is stored remotely.
|
|
|
|
Returns:
|
|
An Occurrence object for the SBOM reference.
|
|
"""
|
|
messages = ca_requests.GetMessages()
|
|
|
|
sbom_digsets = messages.SbomReferenceIntotoPredicate.DigestValue()
|
|
for k, v in sbom.digests.items():
|
|
sbom_digsets.additionalProperties.append(
|
|
messages.SbomReferenceIntotoPredicate.DigestValue.AdditionalProperty(
|
|
key=k,
|
|
value=v,
|
|
)
|
|
)
|
|
predicate = messages.SbomReferenceIntotoPredicate(
|
|
digest=sbom_digsets,
|
|
location=storage,
|
|
mimeType=sbom.GetMimeType(),
|
|
referrerId=_SBOM_REFERENCE_REFERRERID,
|
|
)
|
|
|
|
payload = messages.SbomReferenceIntotoPayload(
|
|
predicateType=_SBOM_REFERENCE_PREDICATE_TYPE,
|
|
_type=_SBOM_REFERENCE_TARGET_TYPE,
|
|
predicate=predicate,
|
|
)
|
|
artifact_digests = messages.Subject.DigestValue()
|
|
for k, v in artifact.digests.items():
|
|
artifact_digests.additionalProperties.append(
|
|
messages.Subject.DigestValue.AdditionalProperty(
|
|
key=k,
|
|
value=v,
|
|
)
|
|
)
|
|
sbom_subject = messages.Subject(
|
|
digest=artifact_digests, name=artifact.resource_uri
|
|
)
|
|
payload.subject.append(sbom_subject)
|
|
|
|
ref_occ = messages.SBOMReferenceOccurrence(
|
|
payload=payload,
|
|
payloadType=_SBOM_REFERENCE_PAYLOAD_TYPE,
|
|
)
|
|
# ResourceURI stored in Occurrences should have https:// prefix.
|
|
occ = messages.Occurrence(
|
|
sbomReference=ref_occ,
|
|
noteName=note.name,
|
|
resourceUri=artifact.GetOccurrenceResourceUri(),
|
|
)
|
|
|
|
return occ
|
|
|
|
|
|
def _GetReferenceNoteID(sbom_format, sbom_version):
|
|
sbom_version_encoded = sbom_version.replace('.', '-')
|
|
return 'sbom-{0}-{1}'.format(sbom_format, sbom_version_encoded)
|
|
|
|
|
|
def _GenerateSbomRefOccurrenceListFilter(artifact, sbom, project_id):
|
|
f = filter_util.ContainerAnalysisFilter()
|
|
f.WithResources([artifact.GetOccurrenceResourceUri()])
|
|
f.WithKinds(['SBOM_REFERENCE'])
|
|
note_id = _GetReferenceNoteID(sbom.sbom_format, sbom.version)
|
|
if len(project_id.split('/')) > 1:
|
|
project_id = project_id.split('/')[0]
|
|
f.WithCustomFilter(
|
|
'noteId="{0}" AND noteProjectId="{1}"'.format(note_id, project_id)
|
|
)
|
|
return f.GetFilter()
|
|
|
|
|
|
# TODO(b/279744848): use the PAE function of the third_party/dsse.
|
|
def _PAE(payload_type, payload):
|
|
"""Creates DSSEv1 Pre-Authentication encoding for given type and payload.
|
|
|
|
Args:
|
|
payload_type: str, the SBOM reference payload type.
|
|
payload: bytes, the serialized SBOM reference payload.
|
|
|
|
Returns:
|
|
A bytes of DSSEv1 Pre-Authentication encoding.
|
|
"""
|
|
|
|
return b'DSSEv1 %d %b %d %b' % (
|
|
len(payload_type),
|
|
payload_type.encode('utf-8'),
|
|
len(payload),
|
|
payload,
|
|
)
|
|
|
|
|
|
def _SignSbomRefOccurrencePayload(occ, kms_key_version):
|
|
"""Add signatures in reference occurrence by using the given kms key.
|
|
|
|
Args:
|
|
occ: Occurrence, the SBOM reference occurrence object we want to sign.
|
|
kms_key_version: str, a kms key used to sign the reference occurrence.
|
|
|
|
Returns:
|
|
An Occurrence object with signatures added.
|
|
"""
|
|
|
|
payload_bytes = six.ensure_binary(
|
|
encoding.MessageToJson(occ.sbomReference.payload)
|
|
)
|
|
data = _PAE(occ.sbomReference.payloadType, payload_bytes)
|
|
|
|
kms_client = cloudkms_base.GetClientInstance()
|
|
kms_messages = cloudkms_base.GetMessagesModule()
|
|
req = kms_messages.CloudkmsProjectsLocationsKeyRingsCryptoKeysCryptoKeyVersionsAsymmetricSignRequest( # pylint: disable=line-too-long
|
|
name=kms_key_version,
|
|
asymmetricSignRequest=kms_messages.AsymmetricSignRequest(data=data),
|
|
)
|
|
resp = kms_client.projects_locations_keyRings_cryptoKeys_cryptoKeyVersions.AsymmetricSign( # pylint: disable=line-too-long
|
|
req
|
|
)
|
|
messages = ca_requests.GetMessages()
|
|
evelope_signature = messages.EnvelopeSignature(
|
|
keyid=kms_key_version, sig=resp.signature
|
|
)
|
|
|
|
occ.envelope = messages.Envelope(
|
|
payload=payload_bytes,
|
|
payloadType=occ.sbomReference.payloadType,
|
|
signatures=[evelope_signature],
|
|
)
|
|
occ.sbomReference.signatures.append(evelope_signature)
|
|
|
|
return occ
|
|
|
|
|
|
def WriteReferenceOccurrence(
|
|
artifact, project_id, storage, sbom, kms_key_version
|
|
):
|
|
"""Write the reference occurrence to link the artifact and the SBOM.
|
|
|
|
Args:
|
|
artifact: Artifact, the artifact metadata SBOM file generated from.
|
|
project_id: str, the project_id where we will use to store the Occurrence.
|
|
storage: str, the path that SBOM is stored remotely.
|
|
sbom: SbomFile, metadata of the SBOM file.
|
|
kms_key_version: str, the kms key to sign the reference occurrence payload.
|
|
|
|
Returns:
|
|
A str for occurrence ID.
|
|
"""
|
|
# Check if the note exists or not.
|
|
note = _CreateSbomRefNoteIfNotExists(project_id, sbom)
|
|
|
|
# Generate the occurrence.
|
|
occ = _GenerateSbomRefOccurrence(artifact, sbom, note, storage)
|
|
|
|
if kms_key_version:
|
|
occ = _SignSbomRefOccurrencePayload(occ, kms_key_version)
|
|
|
|
# Check existing occurrence for updates.
|
|
f = _GenerateSbomRefOccurrenceListFilter(artifact, sbom, project_id)
|
|
log.debug('listing occurrence with filter {0}.'.format(f))
|
|
client = ca_requests.GetClient()
|
|
messages = ca_requests.GetMessages()
|
|
occs = ca_requests.ListOccurrences(project_id, f, None)
|
|
log.debug('list successfully: {}'.format(occs))
|
|
old_occ = None
|
|
for o in occs:
|
|
old_occ = o
|
|
break
|
|
|
|
# Write the reference occurrence.
|
|
if old_occ:
|
|
log.debug('updating occurrence {0}.'.format(old_occ.name))
|
|
request = messages.ContaineranalysisProjectsOccurrencesPatchRequest(
|
|
name=old_occ.name,
|
|
occurrence=occ,
|
|
updateMask='sbom_reference,envelope',
|
|
)
|
|
occ = client.projects_occurrences.Patch(request)
|
|
else:
|
|
request = messages.ContaineranalysisProjectsOccurrencesCreateRequest(
|
|
occurrence=occ,
|
|
parent='projects/{project}'.format(project=project_id),
|
|
)
|
|
occ = client.projects_occurrences.Create(request)
|
|
|
|
log.debug('Used occurrence: {0}.'.format(occ))
|
|
return occ.name
|
|
|
|
|
|
def ExportSbom(args):
|
|
"""Export SBOM files for a given AR image.
|
|
|
|
Args:
|
|
args: User input arguments.
|
|
"""
|
|
if not args.uri:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'--uri is required.',
|
|
)
|
|
uri = _RemovePrefix(args.uri, 'https://')
|
|
if docker_util.IsARDockerImage(uri):
|
|
artifact = _GetARDockerImage(uri)
|
|
elif docker_util.IsGCRImage(uri):
|
|
artifact = _GetGCRImage(uri)
|
|
messages = ar_requests.GetMessages()
|
|
settings = ar_requests.GetProjectSettings(artifact.project)
|
|
if (
|
|
settings.legacyRedirectionState
|
|
!= messages.ProjectSettings.LegacyRedirectionStateValueValuesEnum.REDIRECTION_FROM_GCR_IO_ENABLED
|
|
):
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'This command only supports Artifact Registry. You can enable'
|
|
' redirection to use gcr.io repositories in Artifact Registry.'
|
|
)
|
|
else:
|
|
raise ar_exceptions.InvalidInputValueError(
|
|
'{} is not an Artifact Registry image.'.format(uri)
|
|
)
|
|
project = util.GetProject(args)
|
|
if artifact.project:
|
|
project = artifact.project
|
|
parent = util.GetParent(project, args.location)
|
|
resp = ca_requests.ExportSbomV1beta1(
|
|
parent, 'https://{}'.format(artifact.resource_uri)
|
|
)
|
|
log.status.Print(
|
|
'Exporting the SBOM file for resource {}. Discovery occurrence ID: {}'
|
|
.format(
|
|
artifact.resource_uri,
|
|
resp.discoveryOccurrenceId,
|
|
)
|
|
)
|
|
|
|
|
|
class SbomReference(object):
|
|
"""Holder for SBOM reference.
|
|
|
|
Properties:
|
|
occ: SBOM reference occurrence.
|
|
file_info: Information of GCS object SBOM file.
|
|
"""
|
|
|
|
def __init__(self, occ, file_info):
|
|
self._occ = occ
|
|
self._file_info = file_info
|
|
|
|
@property
|
|
def occ(self):
|
|
return self._occ
|
|
|
|
@property
|
|
def file_info(self):
|
|
return self._file_info
|
|
|
|
|
|
class SbomFile(object):
|
|
"""Holder for SBOM file's metadata.
|
|
|
|
Properties:
|
|
sbom_format: Data format of the SBOM file.
|
|
version: Version of the SBOM format.
|
|
digests: A dictionary of digests, where key is the algorithm.
|
|
"""
|
|
|
|
def __init__(self, sbom_format, version):
|
|
self._sbom_format = sbom_format
|
|
self._version = version
|
|
self._digests = dict()
|
|
|
|
def GetMimeType(self):
|
|
if self._sbom_format == _SBOM_FORMAT_SPDX:
|
|
return _SBOM_REFERENCE_SPDX_MIME_TYPE
|
|
if self._sbom_format == _SBOM_FORMAT_CYCLONEDX:
|
|
return _SBOM_REFERENCE_CYCLONEDX_MIME_TYPE
|
|
return _SBOM_REFERENCE_DEFAULT_MIME_TYPE
|
|
|
|
def GetExtension(self):
|
|
if self._sbom_format == _SBOM_FORMAT_SPDX:
|
|
return _SBOM_REFERENCE_SPDX_EXTENSION
|
|
if self._sbom_format == _SBOM_FORMAT_CYCLONEDX:
|
|
return _SBOM_REFERENCE_CYCLONEDX_EXTENSION
|
|
return _SBOM_REFERENCE_DEFAULT_EXTENSION
|
|
|
|
@property
|
|
def digests(self):
|
|
return self._digests
|
|
|
|
@property
|
|
def sbom_format(self):
|
|
return self._sbom_format
|
|
|
|
@property
|
|
def version(self):
|
|
return self._version
|
|
|
|
|
|
class Artifact(object):
|
|
"""Holder for Artifact's metadata.
|
|
|
|
Properties:
|
|
resource_uri: str, Uri will be used when storing as a reference occurrence.
|
|
project: str, Project of the artifact.
|
|
location: str, Location of the artifact.
|
|
digests: A dictionary of digests, where key is the algorithm.
|
|
artifact_type: str, Type of the provided artifact.
|
|
scheme: str, Scheme of the registry.
|
|
"""
|
|
|
|
def __init__(
|
|
self, resource_uri, project, location, digests, artifact_type, scheme
|
|
):
|
|
self._resource_uri = resource_uri
|
|
self._project = project
|
|
self._location = location
|
|
self._digests = digests
|
|
self._artifact_type = artifact_type
|
|
self._scheme = scheme
|
|
|
|
@property
|
|
def resource_uri(self):
|
|
return self._resource_uri
|
|
|
|
@property
|
|
def project(self):
|
|
return self._project
|
|
|
|
@property
|
|
def location(self):
|
|
return self._location
|
|
|
|
@property
|
|
def digests(self):
|
|
return self._digests
|
|
|
|
@property
|
|
def artifact_type(self):
|
|
return self._artifact_type
|
|
|
|
def GetOccurrenceResourceUri(self):
|
|
if self._scheme is None:
|
|
return self.resource_uri
|
|
return '{scheme}://{uri}'.format(scheme=self._scheme, uri=self.resource_uri)
|