diff --git a/README.md b/README.md index 3f3591cb..710bea3b 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=bancolombia_devsecops-engine-tools&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=bancolombia_devsecops-engine-tools) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=bancolombia_devsecops-engine-tools&metric=coverage)](https://sonarcloud.io/summary/new_code?id=bancolombia_devsecops-engine-tools) [![Python Version](https://img.shields.io/badge/python%20-%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20-blue)](#) +[![PyPI](https://img.shields.io/pypi/v/devsecops-engine-tools)](https://pypi.org/project/devsecops-engine-tools/) [![Docker Pulls](https://img.shields.io/docker/pulls/bancolombia/devsecops-engine-tools )](https://hub.docker.com/r/bancolombia/devsecops-engine-tools) diff --git a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/defect_dojo/defect_dojo.py b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/defect_dojo/defect_dojo.py index 6e9c720e..9911be71 100644 --- a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/defect_dojo/defect_dojo.py +++ b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/defect_dojo/defect_dojo.py @@ -13,6 +13,7 @@ Engagement, Product, Component, + FindingExclusion ) from devsecops_engine_tools.engine_core.src.domain.model.exclusions import Exclusions from devsecops_engine_tools.engine_core.src.domain.model.report import Report @@ -42,9 +43,11 @@ @dataclass class DefectDojoPlatform(VulnerabilityManagementGateway): + RISK_ACCEPTED = "Risk Accepted" OUT_OF_SCOPE = "Out of Scope" FALSE_POSITIVE = "False Positive" TRANSFERRED_FINDING = "Transferred Finding" + ON_WHITELIST = "On Whitelist" def send_vulnerability_management( self, vulnerability_management: VulnerabilityManagement @@ -204,6 +207,11 @@ def get_findings_excepted(self, service, dict_args, secret_tool, config_tool): "tags": tool, "limit": dd_limits_query, } + white_list_query_params = { + "risk_status": self.ON_WHITELIST, + "tags": tool, + "limit": dd_limits_query, + } exclusions_risk_accepted = self._get_findings_with_exclusions( session_manager, @@ -212,7 +220,7 @@ def get_findings_excepted(self, service, dict_args, secret_tool, config_tool): risk_accepted_query_params, tool, self._format_date_to_dd_format, - "Risk Accepted", + self.RISK_ACCEPTED, ) exclusions_false_positive = self._get_findings_with_exclusions( @@ -245,11 +253,29 @@ def get_findings_excepted(self, service, dict_args, secret_tool, config_tool): self.TRANSFERRED_FINDING, ) + white_list = self._get_finding_exclusion( + session_manager, dd_max_retries, { + "type": "white_list", + } + ) + + exclusions_white_list = self._get_findings_with_exclusions( + session_manager, + service, + dd_max_retries, + white_list_query_params, + tool, + self._format_date_to_dd_format, + self.ON_WHITELIST, + white_list=white_list, + ) + return ( list(exclusions_risk_accepted) + list(exclusions_false_positive) + list(exclusions_out_of_scope) + list(exclusions_transfer_finding) + + list(exclusions_white_list) ) except Exception as ex: raise ExceptionFindingsExcepted( @@ -273,8 +299,10 @@ def get_all(self, service, dict_args, secret_tool, config_tool): "HOST_DEFECT_DOJO" ] + session_manager = self._get_session_manager(dict_args, secret_tool, config_tool) + findings = self._get_findings( - self._get_session_manager(dict_args, secret_tool, config_tool), + session_manager, service, max_retries, all_findings_query_params, @@ -287,8 +315,14 @@ def get_all(self, service, dict_args, secret_tool, config_tool): ) ) + white_list = self._get_finding_exclusion( + session_manager, max_retries, { + "type": "white_list", + } + ) + all_exclusions = self._get_report_exclusions( - all_findings, self._format_date_to_dd_format, host_dd=host_dd + all_findings, self._format_date_to_dd_format, host_dd=host_dd, white_list=white_list ) return all_findings, all_exclusions @@ -462,25 +496,25 @@ def _get_session_manager(self, dict_args, secret_tool, config_tool): config_tool["VULNERABILITY_MANAGER"]["DEFECT_DOJO"]["HOST_DEFECT_DOJO"], ) - def _get_report_exclusions(self, total_findings, date_fn, host_dd): + def _get_report_exclusions(self, total_findings, date_fn, host_dd, **kwargs): exclusions = [] for finding in total_findings: if finding.risk_accepted: exclusions.append( self._create_report_exclusion( - finding, date_fn, "engine_risk", "Risk Accepted", host_dd + finding, date_fn, "engine_risk", self.RISK_ACCEPTED, host_dd, **kwargs ) ) elif finding.false_p: exclusions.append( self._create_report_exclusion( - finding, date_fn, "engine_risk", self.FALSE_POSITIVE, host_dd + finding, date_fn, "engine_risk", self.FALSE_POSITIVE, host_dd, **kwargs ) ) elif finding.out_of_scope: exclusions.append( self._create_report_exclusion( - finding, date_fn, "engine_risk", self.OUT_OF_SCOPE, host_dd + finding, date_fn, "engine_risk", self.OUT_OF_SCOPE, host_dd, **kwargs ) ) elif finding.risk_status == "Transfer Accepted": @@ -491,18 +525,26 @@ def _get_report_exclusions(self, total_findings, date_fn, host_dd): "engine_risk", self.TRANSFERRED_FINDING, host_dd, + **kwargs + ) + ) + elif finding.risk_status == self.ON_WHITELIST: + exclusions.append( + self._create_report_exclusion( + finding, date_fn, "engine_risk", self.ON_WHITELIST, host_dd, **kwargs ) ) return exclusions def _get_findings_with_exclusions( - self, session_manager, service, max_retries, query_params, tool, date_fn, reason + self, session_manager, service, max_retries, query_params, tool, date_fn, reason, **kwargs ): findings = self._get_findings( session_manager, service, max_retries, query_params ) + return map( - partial(self._create_exclusion, date_fn=date_fn, tool=tool, reason=reason), + partial(self._create_exclusion, date_fn=date_fn, tool=tool, reason=reason, **kwargs), findings, ) @@ -513,6 +555,14 @@ def request_func(): ).results return self._retries_requests(request_func, max_retries, retry_delay=5) + + def _get_finding_exclusion(self, session_manager, max_retries, query_params): + def request_func(): + return FindingExclusion.get_finding_exclusion( + session=session_manager, **query_params + ).results + + return self._retries_requests(request_func, max_retries, retry_delay=5) def _retries_requests(self, request_func, max_retries, retry_delay): for attempt in range(max_retries): @@ -527,23 +577,34 @@ def _retries_requests(self, request_func, max_retries, retry_delay): logger.error("Maximum number of retries reached, aborting.") raise e - def _date_reason_based(self, finding, date_fn, reason): - if reason in [self.FALSE_POSITIVE, self.OUT_OF_SCOPE]: - create_date = date_fn(finding.last_status_update) - expired_date = date_fn(None) - elif reason == self.TRANSFERRED_FINDING: - create_date = date_fn(finding.transfer_finding.date) - expired_date = date_fn(finding.transfer_finding.expiration_date) - else: - last_accepted_risk = finding.accepted_risks[-1] - create_date = date_fn(last_accepted_risk["created"]) - expired_date = date_fn(last_accepted_risk["expiration_date"]) - return create_date, expired_date + def _date_reason_based(self, finding, date_fn, reason, tool, **kwargs): + def get_vuln_id(finding, tool): + if tool == "engine_risk": + return finding.id[0]["vulnerability_id"] if finding.id else finding.vuln_id_from_tool + else: + return finding.vulnerability_ids[0]["vulnerability_id"] if finding.vulnerability_ids else finding.vuln_id_from_tool + + def get_dates_from_whitelist(vuln_id, white_list): + matching_finding = next(filter(lambda x: x.unique_id_from_tool == vuln_id, white_list), None) + if matching_finding: + return date_fn(matching_finding.create_date), date_fn(matching_finding.expiration_date) + return date_fn(None), date_fn(None) + + reason_to_dates = { + self.FALSE_POSITIVE: lambda: (date_fn(finding.last_status_update), date_fn(None)), + self.OUT_OF_SCOPE: lambda: (date_fn(finding.last_status_update), date_fn(None)), + self.TRANSFERRED_FINDING: lambda: (date_fn(finding.transfer_finding.date), date_fn(finding.transfer_finding.expiration_date)), + self.RISK_ACCEPTED: lambda: (date_fn(finding.accepted_risks[-1]["created"]), date_fn(finding.accepted_risks[-1]["expiration_date"])), + self.ON_WHITELIST: lambda: get_dates_from_whitelist(get_vuln_id(finding, tool), kwargs.get("white_list", [])), + } - def _create_exclusion(self, finding, date_fn, tool, reason): - create_date, expired_date = self._date_reason_based(finding, date_fn, reason) + create_date, expired_date = reason_to_dates.get(reason, lambda: (date_fn(None), date_fn(None)))() + return create_date, expired_date + def _create_exclusion(self, finding, date_fn, tool, reason, **kwargs): + create_date, expired_date = self._date_reason_based(finding, date_fn, reason, tool, **kwargs) + return Exclusions( id=( finding.vuln_id_from_tool @@ -561,8 +622,8 @@ def _create_exclusion(self, finding, date_fn, tool, reason): reason=reason, ) - def _create_report_exclusion(self, finding, date_fn, tool, reason, host_dd): - create_date, expired_date = self._date_reason_based(finding, date_fn, reason) + def _create_report_exclusion(self, finding, date_fn, tool, reason, host_dd, **kwargs): + create_date, expired_date = self._date_reason_based(finding, date_fn, reason, tool, **kwargs) return Exclusions( id=( diff --git a/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/defect_dojo/test_defect_dojo.py b/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/defect_dojo/test_defect_dojo.py index 77ad9b43..b5a129f0 100644 --- a/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/defect_dojo/test_defect_dojo.py +++ b/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/defect_dojo/test_defect_dojo.py @@ -9,11 +9,14 @@ VulnerabilityManagement, ) from devsecops_engine_tools.engine_core.src.domain.model.component import Component -from devsecops_engine_tools.engine_utilities.defect_dojo.domain.models.engagement import Engagement +from devsecops_engine_tools.engine_utilities.defect_dojo.domain.models.engagement import ( + Engagement, +) from devsecops_engine_tools.engine_core.src.domain.model.customs_exceptions import ( ExceptionVulnerabilityManagement, ) + class TestDefectDojoPlatform(unittest.TestCase): def setUp(self): self.vulnerability_management = VulnerabilityManagement @@ -27,7 +30,7 @@ def test_send_vulnerability_management(self, mock_send_import_scan): "token_vulnerability_management": "token1", "token_cmdb": "token2", "tool": "engine_iac", - "platform": ["k8s"] + "platform": ["k8s"], } self.vulnerability_management.secret_tool = { "token_defect_dojo": "token3", @@ -52,21 +55,19 @@ def test_send_vulnerability_management(self, mock_send_import_scan): "PRODUCT_NAME": "nombreapp", "TAG_PRODUCT": "nombreentorno", "PRODUCT_DESCRIPTION": "arearesponsableti", - "CODIGO_APP": "CodigoApp" + "CODIGO_APP": "CodigoApp", }, "CMDB_REQUEST_RESPONSE": { "HEADERS": { "Content-Type": "application/json", - "tokenkey": "tokenvalue" + "tokenkey": "tokenvalue", }, "METHOD": "POST", - "BODY": { - "codapp": "codappvalue" - }, - "RESPONSE": [0] - } - } - } + "BODY": {"codapp": "codappvalue"}, + "RESPONSE": [0], + }, + }, + }, } } self.vulnerability_management.access_token = "access_token" @@ -106,13 +107,11 @@ def test_send_vulnerability_management(self, mock_send_import_scan): cmdb_request_response={ "HEADERS": { "Content-Type": "application/json", - "tokenkey": "tokenvalue" + "tokenkey": "tokenvalue", }, "METHOD": "POST", - "BODY": { - "codapp": "codappvalue" - }, - "RESPONSE": [0] + "BODY": {"codapp": "codappvalue"}, + "RESPONSE": [0], }, expression="regex", token_defect_dojo="token1", @@ -147,7 +146,6 @@ def test_send_vulnerability_management_exception(self): in str(context.exception) ) - def test_build_request_with_cmdb(self): use_cmdb = True tags = "engine_iac_k8s" @@ -179,32 +177,30 @@ def test_build_request_with_cmdb(self): "PRODUCT_NAME": "nombreapp", "TAG_PRODUCT": "nombreentorno", "PRODUCT_DESCRIPTION": "arearesponsableti", - "CODIGO_APP": "CodigoApp" + "CODIGO_APP": "CodigoApp", }, "CMDB_REQUEST_RESPONSE": { "HEADERS": { "Content-Type": "application/json", - "tokenkey": "tokenvalue" + "tokenkey": "tokenvalue", }, "METHOD": "POST", - "BODY": { - "codapp": "codappvalue" - }, - "RESPONSE": [0] - } - } - } + "BODY": {"codapp": "codappvalue"}, + "RESPONSE": [0], + }, + }, + }, } } - self.vulnerability_management.base_compact_remote_config_url = "http://example.com/" + self.vulnerability_management.base_compact_remote_config_url = ( + "http://example.com/" + ) self.vulnerability_management.access_token = "access_token" self.token_cmdb = "token_cmdb" self.token_dd = "token_dd" - self.scan_type_mapping = { - "CHECKOV": "Checkov Scan" - } + self.scan_type_mapping = {"CHECKOV": "Checkov Scan"} self.enviroment_mapping = { "dev": "Development", "qa": "Staging", @@ -242,13 +238,11 @@ def test_build_request_with_cmdb(self): cmdb_request_response={ "HEADERS": { "Content-Type": "application/json", - "tokenkey": "tokenvalue" + "tokenkey": "tokenvalue", }, "METHOD": "POST", - "BODY": { - "codapp": "codappvalue" - }, - "RESPONSE": [0] + "BODY": {"codapp": "codappvalue"}, + "RESPONSE": [0], }, scan_type="Checkov Scan", file="file_path", @@ -266,7 +260,7 @@ def test_build_request_with_cmdb(self): expression="regex", ) self.assertEqual(result, "cmdb_request_result") - + def test_build_request_without_cmdb(self): use_cmdb = False tags = "engine_iac_k8s" @@ -291,22 +285,19 @@ def test_build_request_without_cmdb(self): "DEFECT_DOJO": { "HOST_DEFECT_DOJO": "host_defect_dojo", "MAX_RETRIES_QUERY": 5, - "CMDB": { - "USE_CMDB": True, - "REGEX_EXPRESSION_CMDB": "regex" - } - } + "CMDB": {"USE_CMDB": True, "REGEX_EXPRESSION_CMDB": "regex"}, + }, } } - self.vulnerability_management.base_compact_remote_config_url = "http://example.com/" + self.vulnerability_management.base_compact_remote_config_url = ( + "http://example.com/" + ) self.vulnerability_management.access_token = "access_token" self.token_cmdb = "token_cmdb" self.token_dd = "token_dd" - self.scan_type_mapping = { - "CHECKOV": "Checkov Scan" - } + self.scan_type_mapping = {"CHECKOV": "Checkov Scan"} self.enviroment_mapping = { "dev": "Development", "qa": "Staging", @@ -374,9 +365,7 @@ def test_get_product_type_service( "HOST_DEFECT_DOJO": "host_defect_dojo", "LIMITS_QUERY": 80, "MAX_RETRIES_QUERY": 5, - "CMDB": { - "REGEX_EXPRESSION_CMDB": "regex" - } + "CMDB": {"REGEX_EXPRESSION_CMDB": "regex"}, } } } @@ -406,13 +395,25 @@ def test_get_product_type_service( mock_session_manager.assert_called_with("token1", "host_defect_dojo") self.assertIsNotNone(result) + @patch( + "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.DefectDojoPlatform._date_reason_based" + ) + @patch( + "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.FindingExclusion.get_finding_exclusion" + ) @patch( "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.SessionManager" ) @patch( "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.Finding.get_finding" ) - def test_get_findings_excepted(self, mock_finding, mock_session_manager): + def test_get_findings_excepted( + self, + mock_finding, + mock_session_manager, + mock_finding_exclusion, + mock_date_reason_based, + ): service = "test" dict_args = {"tool": "engine_iac", "token_vulnerability_management": "token1"} secret_tool = {"token_defect_dojo": "token2"} @@ -504,8 +505,76 @@ def test_get_findings_excepted(self, mock_finding, mock_session_manager): ), ] ), + # Findings Whitelist + MagicMock( + results=[ + MagicMock(vuln_id_from_tool="CVE-2024-0001", file_path="path1"), + MagicMock(vuln_id_from_tool="CVE-2024-0002", file_path="path2"), + ] + ), + ] + mock_finding.return_value.results = findings_list + + findings_exclusion_list = [ + MagicMock( + uuid="id1", + unique_id_from_tool="CVE-2024-0001", + type="white_list", + create_date="2024-02-21T00:00:00Z", + expiration_date="2024-02-29T00:00:00Z", + ), + MagicMock( + uuid="id2", + unique_id_from_tool="CVE-2024-0002", + type="white_list", + create_date="2024-02-21T00:00:00Z", + expiration_date="2024-02-29T00:00:00Z", + ), + ] + mock_finding_exclusion.return_value.results = findings_exclusion_list + + mock_date_reason_based.side_effect = [ + ( + "10012024", + "10042024", + ), + ( + "15012024", + "10062024", + ), + ( + "10062024", + "", + ), + ( + "10062024", + "", + ), + ( + "10012024", + "", + ), + ( + "10012024", + "", + ), + ( + "14082024", + "15082024", + ), + ( + "14082024", + "15082024", + ), + ( + "21022024", + "29022024", + ), + ( + "21022024", + "29022024", + ), ] - mock_finding.side_effect = findings_list result = self.defect_dojo.get_findings_excepted( service, dict_args, secret_tool, config_tool @@ -526,7 +595,7 @@ def test_get_findings_excepted(self, mock_finding, mock_session_manager): Exclusions( id="id2", where="path2", create_date="10062024", expired_date="" ), - Exclusions( + Exclusions( id="id1", where="path1", create_date="10012024", expired_date="" ), Exclusions( @@ -538,16 +607,30 @@ def test_get_findings_excepted(self, mock_finding, mock_session_manager): Exclusions( id="id4", where="path2", create_date="14082024", expired_date="15082024" ), + Exclusions( + id="id1", where="path1", create_date="21022024", expired_date="29022024" + ), + Exclusions( + id="id2", where="path2", create_date="21022024", expired_date="29022024" + ), ] self.assertEqual(result, expected_result) + @patch( + "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.FindingExclusion.get_finding_exclusion" + ) @patch( "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.SessionManager" ) @patch( "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.Finding.get_finding" ) - def test_get_findings_excepted_sca(self, mock_finding, mock_session_manager): + def test_get_findings_excepted_sca( + self, + mock_finding, + mock_session_manager, + mock_finding_exclusion, + ): service = "test" dict_args = { "tool": "engine_dependencies", @@ -601,9 +684,13 @@ def test_get_findings_excepted_sca(self, mock_finding, mock_session_manager): MagicMock(results=[]), # Findings Transferred Finding MagicMock(results=[]), + # Findings Whitelist + MagicMock(results=[]), ] mock_finding.side_effect = findings_list + mock_finding_exclusion.return_value.results = [] + result = self.defect_dojo.get_findings_excepted( service, dict_args, secret_tool, config_tool ) @@ -612,7 +699,7 @@ def test_get_findings_excepted_sca(self, mock_finding, mock_session_manager): mock_finding.assert_called_with( session=mock_session_manager.return_value, service=service, - risk_status="Transfer Accepted", + risk_status="On Whitelist", tags="engine_dependencies", limit=80, ) @@ -675,7 +762,7 @@ def test_get_findings_excepted_exception(self): ) @patch( - "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.DefectDojoPlatform._format_date_to_dd_format" + "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.FindingExclusion.get_finding_exclusion" ) @patch( "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.SessionManager" @@ -687,7 +774,11 @@ def test_get_findings_excepted_exception(self): "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.DefectDojoPlatform._get_report_exclusions" ) def test_get_all( - self, mock_exclusions, mock_finding, mock_session_manager, mock_format_date + self, + mock_exclusions, + mock_finding, + mock_session_manager, + mock_finding_exclusion, ): service = "test" dict_args = { @@ -760,6 +851,25 @@ def test_get_all( ), ] mock_finding.return_value.results = findings_list + + findings_exclusion_list = [ + MagicMock( + uuid="id1", + unique_id_from_tool="CVE-2024-0001", + type="white_list", + create_date="2024-02-21T00:00:00Z", + expiration_date="2024-02-29T00:00:00Z", + ), + MagicMock( + uuid="id2", + unique_id_from_tool="CVE-2024-0002", + type="white_list", + create_date="2024-02-21T00:00:00Z", + expiration_date="2024-02-29T00:00:00Z", + ), + ] + mock_finding_exclusion.return_value.results = findings_exclusion_list + expected_result = [ Report( id="id2", @@ -889,13 +999,22 @@ def test_get_report_exclusions(self, mock_create_report_exclusion): false_p=None, risk_status=None, ), + MagicMock( + risk_accepted=None, + out_of_scope=None, + false_p=None, + risk_status="On Whitelist", + vuln_id_from_tool="CVE-2024-0001", + ), ] date_fn = MagicMock() host_dd = "host_defect_dojo" - exclusions = self.defect_dojo._get_report_exclusions(total_findings, date_fn, host_dd) + exclusions = self.defect_dojo._get_report_exclusions( + total_findings, date_fn, host_dd + ) - assert len(exclusions) == 4 + assert len(exclusions) == 5 @patch( "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.Engagement" @@ -903,33 +1022,45 @@ def test_get_report_exclusions(self, mock_create_report_exclusion): @patch( "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.SessionManager" ) - @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.Component') - def test_send_sbom_components_success(self, mock_component, mock_session_manager, mock_engagement): + @patch( + "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.Component" + ) + def test_send_sbom_components_success( + self, mock_component, mock_session_manager, mock_engagement + ): # Configurar los mocks - mock_engagement.get_engagements.return_value.results = [Engagement(id=1, name='test_service')] + mock_engagement.get_engagements.return_value.results = [ + Engagement(id=1, name="test_service") + ] mock_session_manager.return_value = MagicMock() mock_component.get_component.return_value.results = [] - mock_component.create_component.return_value = Component(name='component_name', version='1.0') - + mock_component.create_component.return_value = Component( + name="component_name", version="1.0" + ) # Datos de prueba - sbom_components = [Component(name='component1', version='1.0'), Component(name='component2', version='2.0')] - service = 'test_service' - dict_args = {'token_vulnerability_management': 'test_token'} - secret_tool = {'token_defect_dojo': 'secret_token'} + sbom_components = [ + Component(name="component1", version="1.0"), + Component(name="component2", version="2.0"), + ] + service = "test_service" + dict_args = {"token_vulnerability_management": "test_token"} + secret_tool = {"token_defect_dojo": "secret_token"} config_tool = { - 'VULNERABILITY_MANAGER': { - 'DEFECT_DOJO': { - 'HOST_DEFECT_DOJO': 'http://defectdojo', - 'MAX_RETRIES_QUERY': 3, - 'LIMITS_QUERY': 100 + "VULNERABILITY_MANAGER": { + "DEFECT_DOJO": { + "HOST_DEFECT_DOJO": "http://defectdojo", + "MAX_RETRIES_QUERY": 3, + "LIMITS_QUERY": 100, } } } # Llamar a la función - self.defect_dojo.send_sbom_components(sbom_components, service, dict_args, secret_tool, config_tool) + self.defect_dojo.send_sbom_components( + sbom_components, service, dict_args, secret_tool, config_tool + ) # Verificar que se llamaron las funciones esperadas mock_session_manager.assert_called_once() @@ -937,31 +1068,152 @@ def test_send_sbom_components_success(self, mock_component, mock_session_manage assert mock_component.get_component.call_count == 2 assert mock_component.create_component.call_count == 2 - @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.Engagement') - @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.SessionManager') - def test_send_sbom_components_exception(self, mock_session_manager, mock_engagement): + @patch( + "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.Engagement" + ) + @patch( + "devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.defect_dojo.defect_dojo.SessionManager" + ) + def test_send_sbom_components_exception( + self, mock_session_manager, mock_engagement + ): # Configurar los mocks mock_engagement.get_engagements.side_effect = Exception("Test exception") - # Datos de prueba - sbom_components = [Component(name='component1', version='1.0')] - service = 'test_service' - dict_args = {'token_vulnerability_management': 'test_token'} - secret_tool = {'token_defect_dojo': 'secret_token'} + sbom_components = [Component(name="component1", version="1.0")] + service = "test_service" + dict_args = {"token_vulnerability_management": "test_token"} + secret_tool = {"token_defect_dojo": "secret_token"} config_tool = { - 'VULNERABILITY_MANAGER': { - 'DEFECT_DOJO': { - 'HOST_DEFECT_DOJO': 'http://defectdojo', - 'MAX_RETRIES_QUERY': 3, - 'LIMITS_QUERY': 100 + "VULNERABILITY_MANAGER": { + "DEFECT_DOJO": { + "HOST_DEFECT_DOJO": "http://defectdojo", + "MAX_RETRIES_QUERY": 3, + "LIMITS_QUERY": 100, } } } # Verificar que se lanza la excepción esperada with self.assertRaises(ExceptionVulnerabilityManagement): - self.defect_dojo.send_sbom_components(sbom_components, service, dict_args, secret_tool, config_tool) + self.defect_dojo.send_sbom_components( + sbom_components, service, dict_args, secret_tool, config_tool + ) - + def test_date_reason_based_false_positive(self): + finding = MagicMock() + finding.last_status_update = "2024-01-10T00:00:00Z" + date_fn = MagicMock(return_value="10012024") + reason = self.defect_dojo.FALSE_POSITIVE + tool = "engine_risk" + + create_date, expired_date = self.defect_dojo._date_reason_based( + finding, date_fn, reason, tool + ) + + self.assertEqual(create_date, "10012024") + self.assertEqual(expired_date, date_fn(None)) + + def test_date_reason_based_out_of_scope(self): + finding = MagicMock() + finding.last_status_update = "2024-01-10T00:00:00Z" + date_fn = MagicMock(return_value="10012024") + reason = self.defect_dojo.OUT_OF_SCOPE + tool = "engine_risk" + + create_date, expired_date = self.defect_dojo._date_reason_based( + finding, date_fn, reason, tool + ) + + self.assertEqual(create_date, "10012024") + self.assertEqual(expired_date, date_fn(None)) + + def test_date_reason_based_transferred_finding(self): + finding = MagicMock() + finding.transfer_finding.date = "2024-08-14" + finding.transfer_finding.expiration_date = "2024-08-15T00:00:00Z" + date_fn = MagicMock(side_effect=["14082024", "15082024"]) + reason = self.defect_dojo.TRANSFERRED_FINDING + tool = "engine_risk" + + create_date, expired_date = self.defect_dojo._date_reason_based( + finding, date_fn, reason, tool + ) + + self.assertEqual(create_date, "14082024") + self.assertEqual(expired_date, "15082024") + + def test_date_reason_based_risk_accepted(self): + finding = MagicMock() + finding.accepted_risks = [ + { + "created": "2024-01-10T00:00:00Z", + "expiration_date": "2024-04-10T00:00:00Z", + } + ] + date_fn = MagicMock(side_effect=["10012024", "10042024"]) + reason = self.defect_dojo.RISK_ACCEPTED + tool = "engine_risk" + + create_date, expired_date = self.defect_dojo._date_reason_based( + finding, date_fn, reason, tool + ) + + self.assertEqual(create_date, "10012024") + self.assertEqual(expired_date, "10042024") + + def test_date_reason_based_on_whitelist_engine_risk(self): + finding = MagicMock() + finding.vuln_id_from_tool = "CVE-2024-0001" + date_fn = MagicMock(side_effect=["21022024", "29022024"]) + reason = self.defect_dojo.ON_WHITELIST + tool = "engine_risk" + white_list = [ + MagicMock( + unique_id_from_tool="CVE-2024-0001", + create_date="2024-02-21T00:00:00Z", + expiration_date="2024-02-29T00:00:00Z", + ) + ] + + create_date, expired_date = self.defect_dojo._date_reason_based( + finding, date_fn, reason, tool, white_list=white_list + ) + + self.assertEqual(create_date, "21022024") + self.assertEqual(expired_date, "29022024") + + def test_date_reason_based_on_whitelist(self): + finding = MagicMock() + finding.vulnerability_ids = [{"vulnerability_id": "CVE-2024-0001"}] + date_fn = MagicMock(side_effect=["21022024", "29022024"]) + reason = self.defect_dojo.ON_WHITELIST + tool = "engine_container" + white_list = [ + MagicMock( + unique_id_from_tool="CVE-2024-0001", + create_date="2024-02-21T00:00:00Z", + expiration_date="2024-02-29T00:00:00Z", + ) + ] + + create_date, expired_date = self.defect_dojo._date_reason_based( + finding, date_fn, reason, tool, white_list=white_list + ) + + self.assertEqual(create_date, "21022024") + self.assertEqual(expired_date, "29022024") + + def test_date_reason_based_default(self): + finding = MagicMock() + date_fn = MagicMock(return_value="default_date") + reason = "UNKNOWN_REASON" + tool = "engine_risk" + + create_date, expired_date = self.defect_dojo._date_reason_based( + finding, date_fn, reason, tool + ) + self.assertEqual(create_date, "default_date") + self.assertEqual(expired_date, "default_date") diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/__init__.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/__init__.py index adf61f58..5f37a2de 100644 --- a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/__init__.py +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/__init__.py @@ -5,4 +5,5 @@ from .applications.connect import Connect from .applications.engagement import Engagement from .applications.product import Product -from .applications.component import Component \ No newline at end of file +from .applications.component import Component +from .applications.finding_exclusion import FindingExclusion \ No newline at end of file diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/component.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/component.py index 6ddcdd7c..434a45ce 100644 --- a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/component.py +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/component.py @@ -25,5 +25,4 @@ def create_component(session, request): uc = ComponentUserCase(rest_component) return uc.post(request) except ApiError as e: - logger.error(f"Error during create component: {e}") raise e diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/finding.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/finding.py index 065dd841..fa6973b0 100644 --- a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/finding.py +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/finding.py @@ -1,9 +1,6 @@ -from devsecops_engine_tools.engine_utilities.defect_dojo.domain.request_objects.finding import FindingRequest from devsecops_engine_tools.engine_utilities.defect_dojo.domain.serializers.finding import FindingSerializer from devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.finding import FindingRestConsumer from devsecops_engine_tools.engine_utilities.defect_dojo.domain.user_case.finding import FindingUserCase, FindingGetUserCase -from devsecops_engine_tools.engine_utilities.utils.session_manager import SessionManager -from devsecops_engine_tools.engine_utilities.utils.api_error import ApiError from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger from devsecops_engine_tools.engine_utilities import settings diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/finding_exclusion.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/finding_exclusion.py new file mode 100644 index 00000000..37300dbe --- /dev/null +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/applications/finding_exclusion.py @@ -0,0 +1,14 @@ +from devsecops_engine_tools.engine_utilities.utils.api_error import ApiError +from devsecops_engine_tools.engine_utilities.defect_dojo.domain.user_case.finding_exclusion import FindingExclusionUserCase +from devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.finding_exclusion import FindingExclusionRestConsumer + +class FindingExclusion: + @staticmethod + def get_finding_exclusion(session, **request): + try: + rest_finding_exclusion = FindingExclusionRestConsumer(session=session) + + uc = FindingExclusionUserCase(rest_finding_exclusion) + return uc.execute(request) + except ApiError as e: + raise e \ No newline at end of file diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/models/finding_exclusion.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/models/finding_exclusion.py new file mode 100644 index 00000000..b19e72cc --- /dev/null +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/models/finding_exclusion.py @@ -0,0 +1,20 @@ +import dataclasses +from typing import List +from devsecops_engine_tools.engine_utilities.utils.dataclass_classmethod import FromDictMixin + + +@dataclasses.dataclass +class FindingExclusion(FromDictMixin): + uuid: str = "" + unique_id_from_tool: str = "" + type: str = "" + create_date: str = "" + expiration_date: str = "" + + +@dataclasses.dataclass +class FindingExclusionList(FromDictMixin): + count: int = 0 + next = None + previous = None + results: List[FindingExclusion] = dataclasses.field(default_factory=list) diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/serializers/finding.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/serializers/finding.py index c36f0306..1b3ba91c 100644 --- a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/serializers/finding.py +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/serializers/finding.py @@ -63,7 +63,7 @@ class FindingSerializer(Schema): reviewers = fields.List(fields.Int, requerided=False) risk_accetance = fields.Int(requerided=False) risk_status = fields.Str( - required=False, validate=validate.OneOf(["Risk Pending", "Risk Rejected", "Risk Expired", "Risk Accepted", "Risk Active", "Transfer Pending", "Transfer Rejected", "Transfer Expired", "Transfer Accepted"]) + required=False, validate=validate.OneOf(["Risk Pending", "Risk Rejected", "Risk Expired", "Risk Accepted", "Risk Active", "Transfer Pending", "Transfer Rejected", "Transfer Expired", "Transfer Accepted", "On Whitelist", "On Blacklist"]) ) risk_accepted = fields.Bool(requerided=False) sast_sink_object = fields.Str(requeride=False) diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/user_case/finding_exclusion.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/user_case/finding_exclusion.py new file mode 100644 index 00000000..491c723f --- /dev/null +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/user_case/finding_exclusion.py @@ -0,0 +1,9 @@ +from devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.finding_exclusion import FindingExclusionRestConsumer + +class FindingExclusionUserCase: + def __init__(self, rest_finding_exclusion: FindingExclusionRestConsumer): + self.__rest_finding_exclusion = rest_finding_exclusion + + def execute(self, request): + response = self.__rest_finding_exclusion.get_finding_exclusions(request) + return response diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/user_case/import_scan.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/user_case/import_scan.py index bd3c1655..c1aa4783 100644 --- a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/user_case/import_scan.py +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/user_case/import_scan.py @@ -66,12 +66,12 @@ def execute(self, request: ImportScanRequest) -> ImportScanRequest: with id {product_type_id}" ) - product = self.__rest_product.post_product(request, product_type_id) - product_id = product.id - logger.info( - f"product created: {product.name}\ - found with id: {product.id}" - ) + product = self.__rest_product.post_product(request, product_type_id) + product_id = product.id + logger.info( + f"product created: {product.name}\ + found with id: {product.id}" + ) api_scan_bool = re.search(" API ", request.scan_type) if api_scan_bool: diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/infraestructure/driver_adapters/finding_exclusion.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/infraestructure/driver_adapters/finding_exclusion.py new file mode 100644 index 00000000..eb7c94cb --- /dev/null +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/infraestructure/driver_adapters/finding_exclusion.py @@ -0,0 +1,28 @@ +from devsecops_engine_tools.engine_utilities.utils.api_error import ApiError +from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger +from devsecops_engine_tools.engine_utilities.defect_dojo.domain.models.finding_exclusion import FindingExclusionList +from devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.settings.settings import VERIFY_CERTIFICATE +from devsecops_engine_tools.engine_utilities.utils.session_manager import SessionManager +from devsecops_engine_tools.engine_utilities.settings import SETTING_LOGGER + +logger = MyLogger.__call__(**SETTING_LOGGER).get_logger() + +class FindingExclusionRestConsumer: + def __init__(self, session: SessionManager): + self.__token = session._token + self.__host = session._host + self.__session = session._instance + + + def get_finding_exclusions(self, request) -> FindingExclusionList: + url = f"{self.__host}/api/v2/finding_exclusions/" + headers = {"Authorization": f"Token {self.__token}", "Content-Type": "application/json"} + try: + response = self.__session.get(url, headers=headers, params=request, verify=VERIFY_CERTIFICATE) + if response.status_code != 200: + raise ApiError(response.json()) + finding_exclusions_object = FindingExclusionList.from_dict(response.json()) + except Exception as e: + logger.error(f"from dict FindingExclusion: {e}") + raise ApiError(e) + return finding_exclusions_object diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/applications/test_finding_exclusion.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/applications/test_finding_exclusion.py new file mode 100644 index 00000000..08bcff3d --- /dev/null +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/applications/test_finding_exclusion.py @@ -0,0 +1,36 @@ +import unittest +from unittest.mock import patch, Mock +from devsecops_engine_tools.engine_utilities.utils.api_error import ApiError +from devsecops_engine_tools.engine_utilities.defect_dojo.applications.finding_exclusion import FindingExclusion + +class TestFindingExclusion(unittest.TestCase): + + @patch('devsecops_engine_tools.engine_utilities.defect_dojo.applications.finding_exclusion.FindingExclusionRestConsumer') + @patch('devsecops_engine_tools.engine_utilities.defect_dojo.applications.finding_exclusion.FindingExclusionUserCase') + def test_get_finding_exclusion_success(self, mock_user_case, mock_rest_consumer): + session = Mock() + request = {'key': 'value'} + mock_uc_instance = mock_user_case.return_value + mock_uc_instance.execute.return_value = 'expected_result' + + result = FindingExclusion.get_finding_exclusion(session, **request) + + mock_rest_consumer.assert_called_once_with(session=session) + mock_user_case.assert_called_once_with(mock_rest_consumer.return_value) + mock_uc_instance.execute.assert_called_once_with(request) + self.assertEqual(result, 'expected_result') + + @patch('devsecops_engine_tools.engine_utilities.defect_dojo.applications.finding_exclusion.FindingExclusionRestConsumer') + @patch('devsecops_engine_tools.engine_utilities.defect_dojo.applications.finding_exclusion.FindingExclusionUserCase') + def test_get_finding_exclusion_api_error(self, mock_user_case, mock_rest_consumer): + session = Mock() + request = {'key': 'value'} + mock_uc_instance = mock_user_case.return_value + mock_uc_instance.execute.side_effect = ApiError('API error occurred') + + with self.assertRaises(ApiError): + FindingExclusion.get_finding_exclusion(session, **request) + + mock_rest_consumer.assert_called_once_with(session=session) + mock_user_case.assert_called_once_with(mock_rest_consumer.return_value) + mock_uc_instance.execute.assert_called_once_with(request) \ No newline at end of file diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/domain/user_case/test_finding_exclusion.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/domain/user_case/test_finding_exclusion.py new file mode 100644 index 00000000..b9645093 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/domain/user_case/test_finding_exclusion.py @@ -0,0 +1,32 @@ +import unittest +from unittest.mock import MagicMock +from devsecops_engine_tools.engine_utilities.defect_dojo.domain.user_case.finding_exclusion import FindingExclusionUserCase +from devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.finding_exclusion import FindingExclusionRestConsumer + +class TestFindingExclusionUserCase(unittest.TestCase): + def setUp(self): + self.mock_rest_finding_exclusion = MagicMock(spec=FindingExclusionRestConsumer) + self.user_case = FindingExclusionUserCase(self.mock_rest_finding_exclusion) + + def test_execute_success(self): + request = {"some": "data"} + expected_response = {"response": "data"} + self.mock_rest_finding_exclusion.get_finding_exclusions.return_value = expected_response + + response = self.user_case.execute(request) + + self.assertEqual(response, expected_response) + self.mock_rest_finding_exclusion.get_finding_exclusions.assert_called_once_with(request) + + def test_execute_no_data(self): + request = {} + expected_response = {"response": "no_data"} + self.mock_rest_finding_exclusion.get_finding_exclusions.return_value = expected_response + + response = self.user_case.execute(request) + + self.assertEqual(response, expected_response) + self.mock_rest_finding_exclusion.get_finding_exclusions.assert_called_once_with(request) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/infraestucture/driver_adapter/test_finding_exclusion.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/infraestucture/driver_adapter/test_finding_exclusion.py new file mode 100644 index 00000000..42b9d517 --- /dev/null +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/test/infraestucture/driver_adapter/test_finding_exclusion.py @@ -0,0 +1,57 @@ +import unittest +from unittest.mock import patch, MagicMock +from devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.finding_exclusion import FindingExclusionRestConsumer +from devsecops_engine_tools.engine_utilities.utils.api_error import ApiError + +class TestFindingExclusionRestConsumer(unittest.TestCase): + + @patch('devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.finding_exclusion.SessionManager') + def setUp(self, MockSessionManager): + self.mock_session = MockSessionManager.return_value + self.mock_session._token = 'fake_token' + self.mock_session._host = 'http://fakehost' + self.mock_session._instance = MagicMock() + self.consumer = FindingExclusionRestConsumer(self.mock_session) + + @patch('devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.finding_exclusion.FindingExclusionList') + def test_get_finding_exclusions_success(self, MockFindingExclusionList): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {'key': 'value'} + self.mock_session._instance.get.return_value = mock_response + MockFindingExclusionList.from_dict.return_value = 'finding_exclusion_object' + + request = {'param': 'value'} + result = self.consumer.get_finding_exclusions(request) + + self.mock_session._instance.get.assert_called_once_with( + 'http://fakehost/api/v2/finding_exclusions/', + headers={'Authorization': 'Token fake_token', 'Content-Type': 'application/json'}, + params=request, + verify=False + ) + MockFindingExclusionList.from_dict.assert_called_once_with({'key': 'value'}) + self.assertEqual(result, 'finding_exclusion_object') + + def test_get_finding_exclusions_api_error(self): + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.json.return_value = {'error': 'some error'} + self.mock_session._instance.get.return_value = mock_response + + request = {'param': 'value'} + with self.assertRaises(ApiError): + self.consumer.get_finding_exclusions(request) + + @patch('devsecops_engine_tools.engine_utilities.defect_dojo.infraestructure.driver_adapters.finding_exclusion.logger') + def test_get_finding_exclusions_exception(self, mock_logger): + self.mock_session._instance.get.side_effect = Exception('some exception') + + request = {'param': 'value'} + with self.assertRaises(ApiError): + self.consumer.get_finding_exclusions(request) + + mock_logger.error.assert_called_once_with('from dict FindingExclusion: some exception') + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tools/devsecops_engine_tools/version.py b/tools/devsecops_engine_tools/version.py index c9040165..b1ad4a74 100644 --- a/tools/devsecops_engine_tools/version.py +++ b/tools/devsecops_engine_tools/version.py @@ -1 +1 @@ -version = '1.28.0' +version = '1.31.0'