diff --git a/config/__init__.py b/config/__init__.py index 9459fa9..21a49d6 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -15,6 +15,7 @@ from cxoneflow_logging import SecretRegistry from workflows.state_service import WorkflowStateService from workflows.pull_request import PullRequestWorkflow +from workflows import ResultSeverity, ResultStates from typing import Tuple from multiprocessing import cpu_count @@ -200,7 +201,20 @@ def __workflow_service_client_factory(config_path, moniker, **kwargs): pr_workflow_dict = CxOneFlowConfig.__get_value_for_key_or_default("pull-request", kwargs, {}) scan_monitor_dict = CxOneFlowConfig.__get_value_for_key_or_default("scan-monitor", kwargs, {}) - pr_workflow = PullRequestWorkflow( + exclusions_dict = CxOneFlowConfig.__get_value_for_key_or_default("exclusions", kwargs, {}) + excluded_states = excluded_severities = [] + + try: + excluded_states = [ResultStates(state) for state in CxOneFlowConfig.__get_value_for_key_or_default("state", exclusions_dict, [])] + except ValueError as ve: + raise ConfigurationException(f"{config_path}/exclusions/state {ve}: must be one of {ResultStates.names()}") + + try: + excluded_severities = [ResultSeverity(sev) for sev in CxOneFlowConfig.__get_value_for_key_or_default("severity", exclusions_dict, [])] + except ValueError as ve: + raise ConfigurationException(f"{config_path}/exclusions/severity {ve}: must be one of {ResultSeverity.names()}") + + pr_workflow = PullRequestWorkflow(excluded_severities, excluded_states, CxOneFlowConfig.__get_value_for_key_or_default("enabled", pr_workflow_dict, False), \ int(CxOneFlowConfig.__get_value_for_key_or_default("poll-interval-seconds", scan_monitor_dict, 60)), \ int(CxOneFlowConfig.__get_value_for_key_or_default("scan-timeout-hours", scan_monitor_dict, 48)) \ diff --git a/requirements.txt b/requirements.txt index b9796d1..8ecbb77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ requests aio-pika dataclasses-json markdown - +aenum diff --git a/workflow_agent.py b/workflow_agent.py index 34cd17f..3f18495 100644 --- a/workflow_agent.py +++ b/workflow_agent.py @@ -63,9 +63,9 @@ async def spawn_agents(): if __name__ == '__main__': try: CxOneFlowConfig.bootstrap(get_config_path()) + asyncio.run(spawn_agents()) except ConfigurationException as ce: __log.exception(ce) - asyncio.run(spawn_agents()) diff --git a/workflows/__init__.py b/workflows/__init__.py index 4941326..0f53da5 100644 --- a/workflows/__init__.py +++ b/workflows/__init__.py @@ -1,11 +1,10 @@ from enum import Enum +from aenum import MultiValueEnum class __base_enum(Enum): def __str__(self): return str(self.value) - def __repr__(self): - return str(self.value) class ScanWorkflow(__base_enum): PR = "pr" @@ -20,4 +19,29 @@ class ScanStates(__base_enum): ANNOTATE = "annotate" +class GoofyEnum(MultiValueEnum): + def __repr__(self): + return str(self.value) + + @classmethod + def names(clazz): + return list(clazz._member_map_.values()) + +class ResultStates(GoofyEnum): + TO_VERIFY = "To Verify" + NOT_EXPLOITABLE = "Not Exploitable" + PROP_NOT_EXPLOITABLE = "Proposed Not Exploitable" + CONFIRMED = "Confirmed" + URGENT = "Urgent" + + +class ResultSeverity(GoofyEnum): + CRITICAL = "Critical" + HIGH = "High" + MEDIUM = "Medium" + LOW = "Low" + INFO = "Info", "Information" + + + diff --git a/workflows/pr.py b/workflows/pr.py index 0d5e887..4383585 100644 --- a/workflows/pr.py +++ b/workflows/pr.py @@ -1,7 +1,8 @@ from pathlib import Path from jsonpath_ng import parse from workflows.messaging import PRDetails -from typing import Callable +from typing import Callable, List, Type +from . import ResultSeverity, ResultStates class PullRequestDecoration: @@ -131,10 +132,20 @@ class PullRequestFeedback(PullRequestDecoration): __scanner_stat_query = parse("$.scanInformation.scannerStatus[*]") - def __init__(self, display_url : str, project_id : str, scanid : str, enhanced_report : dict, code_permalink_func : Callable, pr_details : PRDetails): + @staticmethod + def __test_in_enum(clazz : Type, value : str, exclusions : List[Type]): + try: + return clazz(value) in exclusions + except ValueError: + return False + + def __init__(self, excluded_severities : List[ResultSeverity], excluded_states : List[ResultStates], display_url : str, + project_id : str, scanid : str, enhanced_report : dict, code_permalink_func : Callable, pr_details : PRDetails): super().__init__() self.__enhanced_report = enhanced_report self.__permalink = code_permalink_func + self.__excluded_severities = excluded_severities + self.__excluded_states = excluded_states self.__add_annotation_section(display_url, project_id, scanid) self.__add_sast_details(pr_details) @@ -146,64 +157,79 @@ def __add_resolved_details(self): title_added = False for resolved in PullRequestFeedback.__resolved_results_query.find(self.__enhanced_report): for vuln in resolved.value['resolvedVulnerabilities']: - if not title_added: - self.start_resolved_detail_section() - title_added = True for result in vuln['resolvedResults']: - self.add_resolved_detail(PullRequestDecoration.severity_indicator(result['severity']), - vuln['vulnerabilityName'], - PullRequestDecoration.link(vuln['vulnerabilityLink'], "View")) + if not PullRequestFeedback.__test_in_enum(ResultSeverity, result['severity'], self.__excluded_severities): + + if not title_added: + self.start_resolved_detail_section() + title_added = True + + self.add_resolved_detail(PullRequestDecoration.severity_indicator(result['severity']), + vuln['vulnerabilityName'], + PullRequestDecoration.link(vuln['vulnerabilityLink'], "View")) def __add_iac_details(self, pr_details): title_added = False for result in PullRequestFeedback.__iac_results_query.find(self.__enhanced_report): x = result.value - if not title_added: - self.start_iac_detail_section() - title_added = True for query in x['queries']: for result in query['resultsList']: - self.add_iac_detail(PullRequestDecoration.severity_indicator(result['severity']), x['name'], - f"`{result['fileName']}`{PullRequestDecoration.link(self.__permalink(pr_details.organization, - pr_details.repo_project, pr_details.repo_slug, pr_details.source_branch, - result['fileName'], 1), "view")}", query['queryName'], - PullRequestDecoration.link(result['resultViewerLink'], "Risk Details")) + if not (PullRequestFeedback.__test_in_enum(ResultStates, result['state'], self.__excluded_states) or + PullRequestFeedback.__test_in_enum(ResultSeverity, result['severity'], self.__excluded_severities)): + + if not title_added: + self.start_iac_detail_section() + title_added = True + + self.add_iac_detail(PullRequestDecoration.severity_indicator(result['severity']), x['name'], + f"`{result['fileName']}`{PullRequestDecoration.link(self.__permalink(pr_details.organization, + pr_details.repo_project, pr_details.repo_slug, pr_details.source_branch, + result['fileName'], 1), "view")}", query['queryName'], + PullRequestDecoration.link(result['resultViewerLink'], "Risk Details")) def __add_sca_details(self, display_url, project_id, scanid): title_added = False for result in PullRequestFeedback.__sca_results_query.find(self.__enhanced_report): x = result.value - if not title_added: - self.start_sca_detail_section() - title_added = True for category in x['packageCategory']: for cat_result in category['categoryResults']: - self.add_sca_detail(PullRequestDecoration.severity_indicator(cat_result['severity']), - x['packageName'], x['packageVersion'], - PullRequestDecoration.sca_result_link(display_url, project_id, scanid, "Risk Details", - cat_result['cve'], x['packageId'])) + if not (PullRequestFeedback.__test_in_enum(ResultStates, cat_result['state'], self.__excluded_states) or + PullRequestFeedback.__test_in_enum(ResultSeverity, cat_result['severity'], self.__excluded_severities)): + + if not title_added: + self.start_sca_detail_section() + title_added = True + + self.add_sca_detail(PullRequestDecoration.severity_indicator(cat_result['severity']), + x['packageName'], x['packageVersion'], + PullRequestDecoration.sca_result_link(display_url, project_id, scanid, "Risk Details", + cat_result['cve'], x['packageId'])) def __add_sast_details(self, pr_details): title_added = False for result in PullRequestFeedback.__sast_results_query.find(self.__enhanced_report): - if not title_added: - self.start_sast_detail_section() - title_added = True x = result.value describe_link = PullRequestDecoration.link(x['queryDescriptionLink'], x['queryName']) for vuln in x['vulnerabilities']: - self.add_sast_detail(PullRequestDecoration.severity_indicator(vuln['severity']), describe_link, - f"`{vuln['sourceFileName']}`;{PullRequestDecoration.link(self.__permalink(pr_details.organization, - pr_details.repo_project, pr_details.repo_slug, pr_details.source_branch, - vuln['sourceFileName'], vuln['sourceLine']), - vuln['sourceLine'])}", - PullRequestDecoration.link(vuln['resultViewerLink'], "Attack Vector")) + if not (PullRequestFeedback.__test_in_enum(ResultStates, vuln['state'], self.__excluded_states) or + PullRequestFeedback.__test_in_enum(ResultSeverity, vuln['severity'], self.__excluded_severities)): + + if not title_added: + self.start_sast_detail_section() + title_added = True + + self.add_sast_detail(PullRequestDecoration.severity_indicator(vuln['severity']), describe_link, + f"`{vuln['sourceFileName']}`;{PullRequestDecoration.link(self.__permalink(pr_details.organization, + pr_details.repo_project, pr_details.repo_slug, pr_details.source_branch, + vuln['sourceFileName'], vuln['sourceLine']), + vuln['sourceLine'])}", + PullRequestDecoration.link(vuln['resultViewerLink'], "Attack Vector")) def __add_annotation_section(self, display_url, project_id, scanid): self.add_to_annotation(f"**Results for Scan ID {PullRequestDecoration.scan_link(display_url, project_id, scanid)}**") diff --git a/workflows/pull_request.py b/workflows/pull_request.py index 7faf56d..446b7d8 100644 --- a/workflows/pull_request.py +++ b/workflows/pull_request.py @@ -1,10 +1,11 @@ import aio_pika, logging, pamqp.commands, pamqp.base from datetime import timedelta from .state_service import WorkflowStateService -from . import ScanWorkflow, ScanStates +from . import ScanWorkflow, ScanStates, ResultSeverity, ResultStates from .workflow_base import AbstractWorkflow from .messaging import ScanAwaitMessage, ScanFeedbackMessage, ScanAnnotationMessage from .messaging.util import compute_drop_by_timestamp +from typing import List class PullRequestWorkflow(AbstractWorkflow): @@ -14,11 +15,23 @@ def log(): return logging.getLogger("PullRequestWorkflow") - def __init__(self, enabled : bool = False, interval_seconds : int = 90, scan_timeout : int = 48): + def __init__(self, excluded_severities : List[ResultSeverity] = [], excluded_states : List[ResultStates] = [], + enabled : bool = False, interval_seconds : int = 90, scan_timeout : int = 48): self.__enabled = enabled + self.__excluded_states = excluded_states + self.__excluded_severities = excluded_severities self.__interval = timedelta(seconds=interval_seconds) self.__scan_timeout = timedelta(hours=scan_timeout) + @property + def excluded_severities(self) -> List[ResultSeverity]: + return self.__excluded_severities + + @property + def excluded_states(self) -> List[ResultStates]: + return self.__excluded_states + + def __feedback_msg_factory(self, projectid : str, scanid : str, moniker : str, **kwargs) -> aio_pika.Message: return aio_pika.Message(ScanFeedbackMessage(projectid=projectid, scanid=scanid, moniker=moniker, state=ScanStates.FEEDBACK, workflow=ScanWorkflow.PR, workflow_details=kwargs).to_binary(), diff --git a/workflows/state_service.py b/workflows/state_service.py index 11f9978..acdf98e 100644 --- a/workflows/state_service.py +++ b/workflows/state_service.py @@ -165,13 +165,16 @@ async def execute_pr_feedback_workflow(self, msg : aio_pika.abc.AbstractIncoming am = ScanFeedbackMessage.from_binary(msg.body) pr_details = PRDetails.from_dict(am.workflow_details) try: - report = await cxone_service.retrieve_report(am.projectid, am.scanid) - if report is None: - await msg.nack() + if await self.__workflow_map[ScanWorkflow.PR].is_enabled(): + report = await cxone_service.retrieve_report(am.projectid, am.scanid) + if report is None: + await msg.nack() + else: + feedback = PullRequestFeedback(self.__workflow_map[ScanWorkflow.PR].excluded_severities, self.__workflow_map[ScanWorkflow.PR].excluded_states, cxone_service.display_link, am.projectid, am.scanid, report, scm_service.create_code_permalink, pr_details) + await scm_service.exec_pr_decorate(pr_details.organization, pr_details.repo_project, pr_details.repo_slug, pr_details.pr_id, + am.scanid, md.markdown(feedback.content, extensions=['tables'])) + await msg.ack() else: - feedback = PullRequestFeedback(cxone_service.display_link, am.projectid, am.scanid, report, scm_service.create_code_permalink, pr_details) - await scm_service.exec_pr_decorate(pr_details.organization, pr_details.repo_project, pr_details.repo_slug, pr_details.pr_id, - am.scanid, md.markdown(feedback.content, extensions=['tables'])) await msg.ack() except CxOneException as ex: WorkflowStateService.log().exception(ex) diff --git a/workflows/workflow_base.py b/workflows/workflow_base.py index c6dc57e..b5bf765 100644 --- a/workflows/workflow_base.py +++ b/workflows/workflow_base.py @@ -1,7 +1,17 @@ import aio_pika +from typing import List +from . import ResultSeverity, ResultStates class AbstractWorkflow: + @property + def excluded_severities(self) -> List[ResultSeverity]: + raise NotImplementedError("excluded_severities") + + @property + def excluded_states(self) -> List[ResultStates]: + raise NotImplementedError("excluded_states") + async def workflow_start(self, mq_client : aio_pika.abc.AbstractRobustConnection, moniker : str, projectid : str, scanid : str, **kwargs): raise NotImplementedError("workflow_start")