# -*- 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. """Implements the command to upload Generic artifacts to a repository.""" from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals from apitools.base.py import exceptions as apitools_exceptions from apitools.base.py import list_pager from googlecloudsdk.api_lib.artifacts import exceptions as ar_exceptions from googlecloudsdk.api_lib.util import apis from googlecloudsdk.calliope import base from googlecloudsdk.command_lib.artifacts import docker_util from googlecloudsdk.command_lib.artifacts import endpoint_util from googlecloudsdk.command_lib.artifacts import flags from googlecloudsdk.command_lib.artifacts import requests as ar_requests from googlecloudsdk.command_lib.artifacts import vex_util from googlecloudsdk.core import properties @base.DefaultUniverseOnly @base.ReleaseTracks(base.ReleaseTrack.GA) class LoadVex(base.Command): """Load VEX data from a CSAF file into Artifact Analysis. Command loads VEX data from a Common Security Advisory Framework (CSAF) file into Artifact Analysis as VulnerabilityAssessment Notes. VEX data tells Artifact Analysis whether vulnerabilities are relevant and how. """ detailed_help = { 'DESCRIPTION': '{description}', 'EXAMPLES': """\ To load a CSAF security advisory file given an artifact in Artifact Registry and the file on disk, run: $ {command} --uri=us-east1-docker.pkg.dev/project123/repository123/someimage@sha256:49765698074d6d7baa82f --source=/path/to/vex/file To load a CSAF security advisory file given an artifact with a tag and a file on disk, run: $ {command} --uri=us-east1-docker.pkg.dev/project123/repository123/someimage:latest --source=/path/to/vex/file """, } ca_client = None ca_messages = None @staticmethod def Args(parser): parser.add_argument( '--uri', required=True, help=( "The path of the artifact in Artifact Registry. A 'gcr.io' image" ' can also be used if redirection is enabled in Artifact Registry.' " Make sure 'artifactregistry.projectsettings.get' permission is" ' granted to the current gcloud user to verify the redirection' ' status.' ), ) parser.add_argument( '--source', required=True, help='The path of the VEX file.', ) parser.add_argument( '--project', required=False, help='The parent project to load security advisory into.', ) flags.GetOptionalAALocationFlag().AddToParser(parser) return def Run(self, args): """Run the generic artifact upload command.""" with endpoint_util.WithRegion(args.location): self.ca_client = apis.GetClientInstance('containeranalysis', 'v1') self.ca_messages = self.ca_client.MESSAGES_MODULE uri = args.uri uri = vex_util.RemoveHTTPS(uri) if docker_util.IsARDockerImage(uri): image, version = docker_util.DockerUrlToImage(uri) image_uri = image.GetDockerString() version_uri = version.GetDockerString() if version else None image_project = image.project elif docker_util.IsGCRImage(uri): image_project, image_uri, version_uri = vex_util.ParseGCRUrl(uri) messages = ar_requests.GetMessages() settings = ar_requests.GetProjectSettings(image_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 = args.project or image_project filename = args.source notes, generic_uri = vex_util.ParseVexFile(filename, image_uri, version_uri) self.writeNotes(notes, project, generic_uri, args.location) return def writeNotes(self, notes, project, uri, location): notes_to_create = [] notes_to_update = [] parent = self.parent(project, location) for note in notes: get_request = self.ca_messages.ContaineranalysisProjectsNotesGetRequest( name='{}/notes/{}'.format(parent, note.key) ) try: self.ca_client.projects_notes.Get(get_request) note_exists = True except apitools_exceptions.HttpNotFoundError: note_exists = False if note_exists: notes_to_update.append(note) else: notes_to_create.append(note) self.batchWriteNotes(notes_to_create, project, location) self.updateNotes(notes_to_update, project, location) # Delete notes that are not in the uploaded file (deleteNotes looks at which # notes are stored in the db but not in the uploaded file and deletes # those). self.deleteNotes(notes, project, uri, location) def batchWriteNotes(self, notes, project, location): # Helper function to validate the artifacts/max_notes_per_batch_request # hidden flag. The value must be an integer between 1 and 1000. def validate_max_notes_per_batch_request(note_limit_str): try: max_notes_per_batch_request = int(note_limit_str) except ValueError: raise ar_exceptions.InvalidInputValueError( 'max_notes_per_batch_request must be an integer' ) if max_notes_per_batch_request < 1 or max_notes_per_batch_request > 1000: raise ar_exceptions.InvalidInputValueError( 'max_notes_per_batch_request must be between 1 and 1000' ) return max_notes_per_batch_request # Helper function to chunk notes into lists of max_notes_per_request size. def chunked(notes): notes_chunk = [] for note in notes: notes_chunk.append(note) if len(notes_chunk) == max_notes_per_batch_request: yield notes_chunk notes_chunk = [] # If there are any notes left over, yield them. if notes_chunk: yield notes_chunk # Default batch size is 1000, based on the Container Analysis API # BatchWriteNotes request. Sometimes the default batch size is reduced for # testing. max_notes_per_batch_request = validate_max_notes_per_batch_request( properties.VALUES.artifacts.max_notes_per_batch_request.Get() ) # Split notes into chunks to avoid exceeding the max notes per batch # request limit. for notes_chunk in chunked(notes): if not notes_chunk: return note_value = self.ca_messages.BatchCreateNotesRequest.NotesValue() note_value.additionalProperties = notes_chunk batch_request = self.ca_messages.BatchCreateNotesRequest( notes=note_value, ) request = ( self.ca_messages.ContaineranalysisProjectsNotesBatchCreateRequest( parent=self.parent(project, location), batchCreateNotesRequest=batch_request, ) ) self.ca_client.projects_notes.BatchCreate(request) def updateNotes(self, notes, project, location): if not notes: return parent = self.parent(project, location) for note in notes: patch_request = ( self.ca_messages.ContaineranalysisProjectsNotesPatchRequest( name='{}/notes/{}'.format(parent, note.key), note=note.value, ) ) self.ca_client.projects_notes.Patch(patch_request) def deleteNotes(self, file_notes, project, uri, location): list_request = self.ca_messages.ContaineranalysisProjectsNotesListRequest( filter='vulnerability_assessment.product.generic_uri="{}"'.format(uri), parent=self.parent(project, location), ) db_notes = list_pager.YieldFromList( service=self.ca_client.projects_notes, request=list_request, field='notes', batch_size_attribute='pageSize', ) cves_in_file = set() for file_note in file_notes: file_uri = file_note.value.vulnerabilityAssessment.product.genericUri file_vulnerability = ( file_note.value.vulnerabilityAssessment.assessment.vulnerabilityId ) if file_uri == uri: cves_in_file.add(file_vulnerability) for db_note in db_notes: db_vulnerability = ( db_note.vulnerabilityAssessment.assessment.vulnerabilityId ) if db_vulnerability not in cves_in_file: delete_request = ( self.ca_messages.ContaineranalysisProjectsNotesDeleteRequest( name=db_note.name ) ) self.ca_client.projects_notes.Delete(delete_request) def parent(self, project, location): if location is not None: return 'projects/{}/locations/{}'.format(project, location) return 'projects/{}'.format(project)