diff --git a/container_registry/container_analysis/.gitignore b/container_registry/container_analysis/.gitignore new file mode 100644 index 00000000000..9e3d04c4950 --- /dev/null +++ b/container_registry/container_analysis/.gitignore @@ -0,0 +1 @@ +venv* diff --git a/container_registry/container_analysis/README.md b/container_registry/container_analysis/README.md new file mode 100644 index 00000000000..73c45c35478 --- /dev/null +++ b/container_registry/container_analysis/README.md @@ -0,0 +1,54 @@ +Google
+Cloud Platform logo + +# Google Cloud Container Analysis Samples + + +Container Analysis scans container images stored in Container Registry for vulnerabilities. +Continuous automated analysis of containers keep you informed about known vulnerabilities so +that you can review and address issues before deployment. + +Additionally, third-party metadata providers can use Container Analysis to store and +retrieve additional metadata for their customers' images, such as packages installed in an image. + + +## Description + +These samples show how to use the [Google Cloud Container Analysis Client Library](https://cloud.google.com/container-registry/docs/reference/libraries). + +## Build and Run +1. **Enable APIs** + - [Enable the Container Analysis API](https://console.cloud.google.com/flows/enableapi?apiid=containeranalysis.googleapis.com) + and create a new project or select an existing project. +1. **Install and Initialize Cloud SDK** + - Follow instructions from the available [quickstarts](https://cloud.google.com/sdk/docs/quickstarts) +1. **Authenticate with GCP** + - Typically, you should authenticate using a [service account key](https://cloud.google.com/docs/authentication/getting-started) +1. **Clone the repo** and cd into this directory + + ``` + git clone https://github.com/GoogleCloudPlatform/python-docs-samples + cd python-docs-samples + ``` + +1. **Set Environment Variables** + + ``` + export GCLOUD_PROJECT="YOUR_PROJECT_ID" + ``` + +1. **Run Tests** + + ``` + nox -s "py36(sample='./container_registry/container_analysis')" + ``` + +## Contributing changes + +* See [CONTRIBUTING.md](../../CONTRIBUTING.md) + +## Licensing + +* See [LICENSE](../../LICENSE) + diff --git a/container_registry/container_analysis/requirements.txt b/container_registry/container_analysis/requirements.txt new file mode 100644 index 00000000000..ac6aa320681 --- /dev/null +++ b/container_registry/container_analysis/requirements.txt @@ -0,0 +1,6 @@ +google-cloud-pubsub == 0.42.1 +google-cloud-containeranalysis == 0.1.0 +grafeas == 0.1.0 +pytest +flaky +mock diff --git a/container_registry/container_analysis/samples.py b/container_registry/container_analysis/samples.py new file mode 100644 index 00000000000..892b47ddb44 --- /dev/null +++ b/container_registry/container_analysis/samples.py @@ -0,0 +1,373 @@ +#!/bin/python +# Copyright 2019 Google LLC +# +# 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. + + +# [START containeranalysis_create_note] +def create_note(note_id, project_id): + """Creates and returns a new vulnerability note.""" + # note_id = 'my-note' + # project_id = 'my-gcp-project' + + from grafeas.grafeas_v1.gapic.enums import Version + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + project_name = grafeas_client.project_path(project_id) + note = { + 'vulnerability': { + 'details': [ + { + 'affected_cpe_uri': 'your-uri-here', + 'affected_package': 'your-package-here', + 'min_affected_version': { + 'kind': Version.VersionKind.MINIMUM + }, + 'fixed_version': { + 'kind': Version.VersionKind.MAXIMUM + } + } + ] + } + } + response = grafeas_client.create_note(project_name, note_id, note) + return response +# [END containeranalysis_create_note] + + +# [START containeranalysis_delete_note] +def delete_note(note_id, project_id): + """Removes an existing note from the server.""" + # note_id = 'my-note' + # project_id = 'my-gcp-project' + + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + note_name = grafeas_client.note_path(project_id, note_id) + + grafeas_client.delete_note(note_name) +# [END containeranalysis_delete_note] + + +# [START ccontaineranalysis_create_occurrence] +def create_occurrence(resource_url, note_id, occurrence_project, note_project): + """ Creates and returns a new occurrence of a previously + created vulnerability note.""" + # resource_url = 'https://gcr.io/my-project/my-image@sha256:123' + # note_id = 'my-note' + # occurrence_project = 'my-gcp-project' + # note_project = 'my-gcp-project' + + from grafeas.grafeas_v1.gapic.enums import Version + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + formatted_note = grafeas_client.note_path(note_project, note_id) + formatted_project = grafeas_client.project_path(occurrence_project) + + occurrence = { + 'note_name': formatted_note, + 'resource_uri': resource_url, + 'vulnerability': { + 'package_issue': [ + { + 'affected_cpe_uri': 'your-uri-here', + 'affected_package': 'your-package-here', + 'min_affected_version': { + 'kind': Version.VersionKind.MINIMUM + }, + 'fixed_version': { + 'kind': Version.VersionKind.MAXIMUM + } + } + ] + } + } + + return grafeas_client.create_occurrence(formatted_project, occurrence) +# [END containeranalysis_create_occurrence] + + +# [START containeranalysis_delete_occurrence] +def delete_occurrence(occurrence_id, project_id): + """Removes an existing occurrence from the server.""" + # occurrence_id = basename(occurrence.name) + # project_id = 'my-gcp-project' + + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + parent = grafeas_client.occurrence_path(project_id, occurrence_id) + grafeas_client.delete_occurrence(parent) +# [END containeranalysis_delete_occurrence] + + +# [START containeranalysis_get_note] +def get_note(note_id, project_id): + """Retrieves and prints a specified note from the server.""" + # note_id = 'my-note' + # project_id = 'my-gcp-project' + + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + note_name = grafeas_client.note_path(project_id, note_id) + response = grafeas_client.get_note(note_name) + return response +# [END containeranalysis_get_note] + + +# [START containeranalysis_get_occurrence] +def get_occurrence(occurrence_id, project_id): + """retrieves and prints a specified occurrence from the server.""" + # occurrence_id = basename(occurrence.name) + # project_id = 'my-gcp-project' + + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + parent = grafeas_client.occurrence_path(project_id, occurrence_id) + return grafeas_client.get_occurrence(parent) +# [END containeranalysis_get_occurrence] + + +# [START containeranalysis_discovery_info] +def get_discovery_info(resource_url, project_id): + """Retrieves and prints the discovery occurrence created for a specified + image. The discovery occurrence contains information about the initial + scan on the image.""" + # resource_url = 'https://gcr.io/my-project/my-image@sha256:123' + # project_id = 'my-gcp-project' + + from google.cloud.devtools import containeranalysis_v1 + + filter_str = 'kind="DISCOVERY" AND resourceUrl="{}"'.format(resource_url) + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + project_name = grafeas_client.project_path(project_id) + response = grafeas_client.list_occurrences(project_name, + filter_=filter_str) + for occ in response: + print(occ) +# [END containeranalysis_discovery_info] + + +# [START containeranalysis_occurrences_for_note] +def get_occurrences_for_note(note_id, project_id): + """Retrieves all the occurrences associated with a specified Note. + Here, all occurrences are printed and counted.""" + # note_id = 'my-note' + # project_id = 'my-gcp-project' + + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + note_name = grafeas_client.note_path(project_id, note_id) + + response = grafeas_client.list_note_occurrences(note_name) + count = 0 + for o in response: + # do something with the retrieved occurrence + # in this sample, we will simply count each one + count += 1 + return count +# [END containeranalysis_occurrences_for_note] + + +# [START containeranalysis_occurrences_for_image] +def get_occurrences_for_image(resource_url, project_id): + """Retrieves all the occurrences associated with a specified image. + Here, all occurrences are simply printed and counted.""" + # resource_url = 'https://gcr.io/my-project/my-image@sha256:123' + # project_id = 'my-gcp-project' + + from google.cloud.devtools import containeranalysis_v1 + + filter_str = 'resourceUrl="{}"'.format(resource_url) + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + project_name = grafeas_client.project_path(project_id) + + response = grafeas_client.list_occurrences(project_name, + filter_=filter_str) + count = 0 + for o in response: + # do something with the retrieved occurrence + # in this sample, we will simply count each one + count += 1 + return count +# [END containeranalysis_occurrences_for_image] + + +# [START containeranalysis_pubsub] +def pubsub(subscription_id, timeout_seconds, project_id): + """Respond to incoming occurrences using a Cloud Pub/Sub subscription.""" + # subscription_id := 'my-occurrences-subscription' + # timeout_seconds = 20 + # project_id = 'my-gcp-project' + + import time + from google.cloud.pubsub import SubscriberClient + + client = SubscriberClient() + subscription_name = client.subscription_path(project_id, subscription_id) + receiver = MessageReceiver() + client.subscribe(subscription_name, receiver.pubsub_callback) + + # listen for 'timeout' seconds + for _ in range(timeout_seconds): + time.sleep(1) + # print and return the number of pubsub messages received + print(receiver.msg_count) + return receiver.msg_count + + +class MessageReceiver: + """Custom class to handle incoming Pub/Sub messages.""" + def __init__(self): + # initialize counter to 0 on initialization + self.msg_count = 0 + + def pubsub_callback(self, message): + # every time a pubsub message comes in, print it and count it + self.msg_count += 1 + print('Message {}: {}'.format(self.msg_count, message.data)) + message.ack() + + +def create_occurrence_subscription(subscription_id, project_id): + """Creates a new Pub/Sub subscription object listening to the + Container Analysis Occurrences topic.""" + # subscription_id := 'my-occurrences-subscription' + # project_id = 'my-gcp-project' + + from google.api_core.exceptions import AlreadyExists + from google.cloud.pubsub import SubscriberClient + + topic_id = 'container-analysis-occurrences-v1' + client = SubscriberClient() + topic_name = client.topic_path(project_id, topic_id) + subscription_name = client.subscription_path(project_id, subscription_id) + success = True + try: + client.create_subscription(subscription_name, topic_name) + except AlreadyExists: + # if subscription already exists, do nothing + pass + else: + success = False + return success +# [END containeranalysis_pubsub] + + +# [START containeranalysis_poll_discovery_occurrence_finished] +def poll_discovery_finished(resource_url, timeout_seconds, project_id): + """Returns the discovery occurrence for a resource once it reaches a + terminal state.""" + # resource_url = 'https://gcr.io/my-project/my-image@sha256:123' + # timeout_seconds = 20 + # project_id = 'my-gcp-project' + + import time + from grafeas.grafeas_v1.gapic.enums import DiscoveryOccurrence + from google.cloud.devtools import containeranalysis_v1 + + deadline = time.time() + timeout_seconds + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + project_name = grafeas_client.project_path(project_id) + + discovery_occurrence = None + while discovery_occurrence is None: + time.sleep(1) + filter_str = 'resourceUrl="{}" \ + AND noteProjectId="goog-analysis" \ + AND noteId="PACKAGE_VULNERABILITY"'.format(resource_url) + # [END containeranalysis_poll_discovery_occurrence_finished] + # The above filter isn't testable, since it looks for occurrences in a + # locked down project fall back to a more permissive filter for testing + filter_str = 'kind="DISCOVERY" AND resourceUrl="{}"'\ + .format(resource_url) + # [START containeranalysis_poll_discovery_occurrence_finished] + result = grafeas_client.list_occurrences(project_name, filter_str) + # only one occurrence should ever be returned by ListOccurrences + # and the given filter + for item in result: + discovery_occurrence = item + if time.time() > deadline: + raise RuntimeError('timeout while retrieving discovery occurrence') + + status = DiscoveryOccurrence.AnalysisStatus.PENDING + while status != DiscoveryOccurrence.AnalysisStatus.FINISHED_UNSUPPORTED \ + and status != DiscoveryOccurrence.AnalysisStatus.FINISHED_FAILED \ + and status != DiscoveryOccurrence.AnalysisStatus.FINISHED_SUCCESS: + time.sleep(1) + updated = grafeas_client.get_occurrence(discovery_occurrence.name) + status = updated.discovery.analysis_status + if time.time() > deadline: + raise RuntimeError('timeout while waiting for terminal state') + return discovery_occurrence +# [END containeranalysis_poll_discovery_occurrence_finished] + + +# [START containeranalysis_vulnerability_occurrences_for_image] +def find_vulnerabilities_for_image(resource_url, project_id): + """"Retrieves all vulnerability occurrences associated with a resource.""" + # resource_url = 'https://gcr.io/my-project/my-image@sha256:123' + # project_id = 'my-gcp-project' + + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + project_name = grafeas_client.project_path(project_id) + + filter_str = 'kind="VULNERABILITY" AND resourceUrl="{}"'\ + .format(resource_url) + return list(grafeas_client.list_occurrences(project_name, filter_str)) +# [END containeranalysis_vulnerability_occurrences_for_image] + + +# [START containeranalysis_filter_vulnerability_occurrences] +def find_high_severity_vulnerabilities_for_image(resource_url, project_id): + """Retrieves a list of only high vulnerability occurrences associated + with a resource.""" + # resource_url = 'https://gcr.io/my-project/my-image@sha256:123' + # project_id = 'my-gcp-project' + + from grafeas.grafeas_v1.gapic.enums import Severity + from google.cloud.devtools import containeranalysis_v1 + + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + project_name = grafeas_client.project_path(project_id) + + filter_str = 'kind="VULNERABILITY" AND resourceUrl="{}"'\ + .format(resource_url) + vulnerabilities = grafeas_client.list_occurrences(project_name, filter_str) + filtered_list = [] + for v in vulnerabilities: + if v.severity == Severity.HIGH or v.severity == Severity.CRITICAL: + filtered_list.append(v) + return filtered_list +# [END containeranalysis_filter_vulnerability_occurrences] diff --git a/container_registry/container_analysis/samples_test.py b/container_registry/container_analysis/samples_test.py new file mode 100644 index 00000000000..e48a6ab8403 --- /dev/null +++ b/container_registry/container_analysis/samples_test.py @@ -0,0 +1,294 @@ +#!/bin/python +# Copyright 2019 Google LLC +# +# 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. + +from os import environ +from os.path import basename +from time import sleep, time + +from google.api_core.exceptions import AlreadyExists +from google.api_core.exceptions import InvalidArgument +from google.api_core.exceptions import NotFound +from google.cloud.devtools import containeranalysis_v1 +from google.cloud.pubsub import PublisherClient, SubscriberClient + +from grafeas.grafeas_v1.gapic.enums import DiscoveryOccurrence +from grafeas.grafeas_v1.gapic.enums import NoteKind +from grafeas.grafeas_v1.gapic.enums import Severity +from grafeas.grafeas_v1.gapic.enums import Version + +import samples + +PROJECT_ID = environ['GCLOUD_PROJECT'] +SLEEP_TIME = 1 +TRY_LIMIT = 20 + + +class TestContainerAnalysisSamples: + + def setup_method(self, test_method): + print('SETUP {}'.format(test_method.__name__)) + timestamp = str(int(time())) + self.note_id = 'note-{}-{}'.format(timestamp, test_method.__name__) + self.image_url = '{}.{}'.format(timestamp, test_method.__name__) + self.note_obj = samples.create_note(self.note_id, PROJECT_ID) + + def teardown_method(self, test_method): + print('TEAR DOWN {}'.format(test_method.__name__)) + try: + samples.delete_note(self.note_id, PROJECT_ID) + except NotFound: + pass + + def test_create_note(self): + new_note = samples.get_note(self.note_id, PROJECT_ID) + assert new_note.name == self.note_obj.name + + def test_delete_note(self): + samples.delete_note(self.note_id, PROJECT_ID) + try: + samples.get_note(self.note_obj, PROJECT_ID) + except InvalidArgument: + pass + else: + # didn't raise exception we expected + assert (False) + + def test_create_occurrence(self): + created = samples.create_occurrence(self.image_url, + self.note_id, + PROJECT_ID, + PROJECT_ID) + retrieved = samples.get_occurrence(basename(created.name), PROJECT_ID) + assert created.name == retrieved.name + # clean up + samples.delete_occurrence(basename(created.name), PROJECT_ID) + + def test_delete_occurrence(self): + created = samples.create_occurrence(self.image_url, + self.note_id, + PROJECT_ID, + PROJECT_ID) + samples.delete_occurrence(basename(created.name), PROJECT_ID) + try: + samples.get_occurrence(basename(created.name), PROJECT_ID) + except NotFound: + pass + else: + # didn't raise exception we expected + assert False + + def test_occurrences_for_image(self): + orig_count = samples.get_occurrences_for_image(self.image_url, + PROJECT_ID) + occ = samples.create_occurrence(self.image_url, + self.note_id, + PROJECT_ID, + PROJECT_ID) + new_count = 0 + tries = 0 + while new_count != 1 and tries < TRY_LIMIT: + tries += 1 + new_count = samples.get_occurrences_for_image(self.image_url, + PROJECT_ID) + sleep(SLEEP_TIME) + assert new_count == 1 + assert orig_count == 0 + # clean up + samples.delete_occurrence(basename(occ.name), PROJECT_ID) + + def test_occurrences_for_note(self): + orig_count = samples.get_occurrences_for_note(self.note_id, + PROJECT_ID) + occ = samples.create_occurrence(self.image_url, + self.note_id, + PROJECT_ID, + PROJECT_ID) + new_count = 0 + tries = 0 + while new_count != 1 and tries < TRY_LIMIT: + tries += 1 + new_count = samples.get_occurrences_for_note(self.note_id, + PROJECT_ID) + sleep(SLEEP_TIME) + assert new_count == 1 + assert orig_count == 0 + # clean up + samples.delete_occurrence(basename(occ.name), PROJECT_ID) + + def test_pubsub(self): + # create topic if needed + client = SubscriberClient() + try: + topic_id = 'container-analysis-occurrences-v1' + topic_name = client.topic_path(PROJECT_ID, topic_id) + publisher = PublisherClient() + publisher.create_topic(topic_name) + except AlreadyExists: + pass + + subscription_id = 'drydockOccurrences' + subscription_name = client.subscription_path(PROJECT_ID, + subscription_id) + samples.create_occurrence_subscription(subscription_id, PROJECT_ID) + tries = 0 + success = False + while not success and tries < TRY_LIMIT: + print(tries) + tries += 1 + receiver = samples.MessageReceiver() + client.subscribe(subscription_name, receiver.pubsub_callback) + + # test adding 3 more occurrences + total_created = 3 + for _ in range(total_created): + occ = samples.create_occurrence(self.image_url, + self.note_id, + PROJECT_ID, + PROJECT_ID) + sleep(SLEEP_TIME) + samples.delete_occurrence(basename(occ.name), PROJECT_ID) + sleep(SLEEP_TIME) + print('done. msg_count = {}'.format(receiver.msg_count)) + success = receiver.msg_count == total_created + assert receiver.msg_count == total_created + # clean up + client.delete_subscription(subscription_name) + + def test_poll_discovery_occurrence(self): + # try with no discovery occurrence + try: + samples.poll_discovery_finished(self.image_url, 5, PROJECT_ID) + except RuntimeError: + pass + else: + # we expect timeout error + assert False + + # create discovery occurrence + note_id = 'discovery-note-{}'.format(int(time())) + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + note = { + 'discovery': { + 'analysis_kind': NoteKind.DISCOVERY + } + } + grafeas_client.\ + create_note(grafeas_client.project_path(PROJECT_ID), note_id, note) + occurrence = { + 'note_name': grafeas_client.note_path(PROJECT_ID, note_id), + 'resource_uri': self.image_url, + 'discovery': { + 'analysis_status': DiscoveryOccurrence.AnalysisStatus + .FINISHED_SUCCESS + } + } + created = grafeas_client.\ + create_occurrence(grafeas_client.project_path(PROJECT_ID), + occurrence) + + # poll again + disc = samples.poll_discovery_finished(self.image_url, 10, PROJECT_ID) + status = disc.discovery.analysis_status + assert disc is not None + assert status == DiscoveryOccurrence.AnalysisStatus.FINISHED_SUCCESS + + # clean up + samples.delete_occurrence(basename(created.name), PROJECT_ID) + samples.delete_note(note_id, PROJECT_ID) + + def test_find_vulnerabilities_for_image(self): + occ_list = samples.find_vulnerabilities_for_image(self.image_url, + PROJECT_ID) + assert len(occ_list) == 0 + + created = samples.create_occurrence(self.image_url, + self.note_id, + PROJECT_ID, + PROJECT_ID) + tries = 0 + count = 0 + while count != 1 and tries < TRY_LIMIT: + tries += 1 + occ_list = samples.find_vulnerabilities_for_image(self.image_url, + PROJECT_ID) + count = len(occ_list) + sleep(SLEEP_TIME) + assert len(occ_list) == 1 + samples.delete_occurrence(basename(created.name), PROJECT_ID) + + def test_find_high_severity_vulnerabilities(self): + occ_list = samples.find_high_severity_vulnerabilities_for_image( + self.image_url, + PROJECT_ID) + assert len(occ_list) == 0 + + # create new high severity vulnerability + note_id = 'discovery-note-{}'.format(int(time())) + client = containeranalysis_v1.ContainerAnalysisClient() + grafeas_client = client.get_grafeas_client() + note = { + 'vulnerability': { + 'severity': Severity.CRITICAL, + 'details': [ + { + 'affected_cpe_uri': 'your-uri-here', + 'affected_package': 'your-package-here', + 'min_affected_version': { + 'kind': Version.VersionKind.MINIMUM + }, + 'fixed_version': { + 'kind': Version.VersionKind.MAXIMUM + } + } + ] + } + } + grafeas_client.\ + create_note(grafeas_client.project_path(PROJECT_ID), note_id, note) + occurrence = { + 'note_name': client.note_path(PROJECT_ID, note_id), + 'resource_uri': self.image_url, + 'vulnerability': { + 'package_issue': [ + { + 'affected_cpe_uri': 'your-uri-here', + 'affected_package': 'your-package-here', + 'min_affected_version': { + 'kind': Version.VersionKind.MINIMUM + }, + 'fixed_version': { + 'kind': Version.VersionKind.MAXIMUM + } + } + ] + } + } + created = grafeas_client.\ + create_occurrence(grafeas_client.project_path(PROJECT_ID), + occurrence) + # query again + tries = 0 + count = 0 + while count != 1 and tries < TRY_LIMIT: + tries += 1 + occ_list = samples.find_vulnerabilities_for_image(self.image_url, + PROJECT_ID) + count = len(occ_list) + sleep(SLEEP_TIME) + assert len(occ_list) == 1 + # clean up + samples.delete_occurrence(basename(created.name), PROJECT_ID) + samples.delete_note(note_id, PROJECT_ID)