Skip to content

Commit

Permalink
feat(PSEC-2268): Move risk assessment reminders to scan results (#3168)
Browse files Browse the repository at this point in the history
1. Added two new fields to the scan results message (total & unrated
vulns):
<img width="473" alt="Screenshot 2024-12-13 at 12 17 59"
src="https://github.com/user-attachments/assets/90eed41f-5405-458c-b139-749062a0d899"
/>


2. In order to reduce noise for risk assessors, reminders for unrated
vulns are now send as response to the scan results message. The first
message pings the risk assessor. After that, a message for each unrated
vuln is send that contains the vuln name and a link to the corresponding
slack thread:
<img width="458" alt="Screenshot 2024-12-13 at 12 26 54"
src="https://github.com/user-attachments/assets/6a63df68-19c5-4964-bbe0-61f407a1e6a1"
/>
  • Loading branch information
tmu0 authored Dec 13, 2024
1 parent 4b40553 commit 4bc7052
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 18 deletions.
11 changes: 8 additions & 3 deletions ci/src/dependencies/data_source/slack_findings_failover/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ class SlackVulnerabilityEventType(Enum):
VULN_ADDED = 1
VULN_REMOVED = 2
VULN_CHANGED = 3
DEP_ADDED = 4
DEP_REMOVED = 5
RISK_UNKNOWN = 6
VULN_UNCHANGED = 4
DEP_ADDED = 5
DEP_REMOVED = 6
RISK_UNKNOWN = 7


@dataclass
Expand All @@ -44,6 +45,10 @@ def vuln_changed(vuln_id: str, channel_id: str, updated_fields: Dict[str, str]):
SlackVulnerabilityEventType.VULN_CHANGED, vuln_id, channel_id, updated_fields=updated_fields
)

@staticmethod
def vuln_unchanged(vuln_id: str, channel_id: str):
return SlackVulnerabilityEvent(SlackVulnerabilityEventType.VULN_UNCHANGED, vuln_id, channel_id)

@staticmethod
def dep_added(vuln_id: str, channel_id: str, finding_id: Tuple[str, str, str, str], added_projects: List[str]):
return SlackVulnerabilityEvent(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
from dataclasses import dataclass, field
from typing import Dict, Set, Tuple
from typing import Dict, List, Optional, Set, Tuple

from data_source.slack_findings_failover.parse_format import get_current_iso_timestamp, project_to_list_item
from integration.slack.slack_block_kit_utils import (
Expand All @@ -18,8 +18,11 @@ class SlackScanResult:
new_vulnerabilities: int = 0
changed_vulnerabilities: int = 0
fixed_vulnerabilities: int = 0
unrated_vulnerabilities: int = 0
total_vulnerabilities: int = 0
added_dependencies: Dict[Tuple[str, str, str, str], Set[str]] = field(default_factory=lambda: {})
removed_dependencies: Dict[Tuple[str, str, str, str], Set[str]] = field(default_factory=lambda: {})
unrated_vulnerabilities_reminder: Dict[str, Tuple[Optional[str], Set[str]]] = field(default_factory=lambda: {})

def has_updates(self):
return (
Expand All @@ -28,20 +31,32 @@ def has_updates(self):
or self.fixed_vulnerabilities > 0
or len(self.added_dependencies) > 0
or len(self.removed_dependencies) > 0
or len(self.unrated_vulnerabilities_reminder) > 0
)

def add_unrated_vulnerabilities_reminder(self, vuln_id: str, permalink: Optional[str], risk_assessors: Set[str]):
if vuln_id in self.unrated_vulnerabilities_reminder:
raise RuntimeError(f"add_unrated_vulnerabilities_reminder was called twice with the same vuln_id {vuln_id}")
self.unrated_vulnerabilities_reminder[vuln_id] = (permalink, risk_assessors)

def get_slack_msg(self, repository: str, scanner: str) -> str:
block_kit_msg = [
block_kit_header("Scan Results"),
block_kit_section_with_two_cols("Repository", repository, "Scanner", scanner),
block_kit_section_with_two_cols(
"Time", get_current_iso_timestamp(), "New Vulnerabilities", str(self.new_vulnerabilities)
"Time", get_current_iso_timestamp(), "Total Vulnerabilities", str(self.total_vulnerabilities)
),
block_kit_section_with_two_cols(
"New Vulnerabilities",
str(self.new_vulnerabilities),
"Changed Vulnerabilities",
str(self.changed_vulnerabilities),
),
block_kit_section_with_two_cols(
"Fixed Vulnerabilities",
str(self.fixed_vulnerabilities),
"Unrated Vulnerabilities",
str(self.unrated_vulnerabilities),
),
block_kit_header("Dependencies"),
]
Expand All @@ -63,3 +78,18 @@ def get_slack_msg(self, repository: str, scanner: str) -> str:
else:
block_kit_msg.append(block_kit_bullet_list_with_headline(BlockKitListHeadline.with_text("No changes"), []))
return json.dumps(block_kit_msg)

def get_slack_thread_msgs_for_reminder(self) -> List[str]:
if len(self.unrated_vulnerabilities_reminder) == 0:
return []

risk_assessors = set()
res = []
for vuln_id, (permalink, ras) in self.unrated_vulnerabilities_reminder.items():
if permalink:
res.append(f"<{permalink}|{vuln_id}>")
else:
res.append(f"{vuln_id}")
risk_assessors.update(ras)

return ["The following findings need risk assessment from " + ", ".join(sorted(list(risk_assessors)))] + res
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,12 @@ def update_with(
if self.vulnerability.score != other.vulnerability.score:
vuln_updated_fields["Score"] = str(self.vulnerability.score) if self.vulnerability.score != -1 else "n/a"
self.vulnerability.score = other.vulnerability.score
if len(vuln_updated_fields) > 0:
for channel in self.msg_info_by_channel.keys():
if channel not in channels_vuln_removed:
for channel in self.msg_info_by_channel.keys():
if channel not in channels_vuln_removed:
if len(vuln_updated_fields) > 0:
vuln_events.append(SlackVulnerabilityEvent.vuln_changed(vuln_id, channel, vuln_updated_fields))
else:
vuln_events.append(SlackVulnerabilityEvent.vuln_unchanged(vuln_id, channel))
return vuln_events + res

def get_slack_msg_for(self, channel_id: str, info_by_project: Dict[str, SlackProjectInfo]) -> Optional[str]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,28 @@ def test_update_with():
assert events[6] in [da1, da2, dr1]
assert events[4] != events[5] and events[5] != events[6] and events[4] != events[6]
assert events[7] == dr2


def test_update_with_vuln_unchanged():
v = Vulnerability("vid", "vname", "vdesc", 8)
sf1 = SlackFinding("repo", "scanner", "did", "dvers", ["proj1", "proj2"])
info_by_project = {
"proj1": SlackProjectInfo("proj1", {"c1"}, {}),
"proj2": SlackProjectInfo("proj1", {"c2"}, {}),
}
svi = SlackVulnerabilityInfo(
v,
{sf1.id(): sf1},
{
"c1": SlackVulnerabilityMessageInfo("c1", "msgid1", {"low-risk-psec"}),
"c2": SlackVulnerabilityMessageInfo("c2", "msgid2", {"high-risk-psec"}),
},
)
f1 = Finding("repo", "scanner", Dependency("did", "dname", "dvers"), [v], [], ["proj1", "proj2"], [])
vi = VulnerabilityInfo(v, {f1.id(): f1})

events = svi.update_with(vi, info_by_project, "repo", "scanner")

assert len(events) == 2
assert events[0] == SlackVulnerabilityEvent.vuln_unchanged("vid", "c1")
assert events[1] == SlackVulnerabilityEvent.vuln_unchanged("vid", "c2")
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ def handle_events(
t = event.type
if t == SlackVulnerabilityEventType.VULN_ADDED:
scan_result_by_channel[event.channel_id].new_vulnerabilities += 1
scan_result_by_channel[event.channel_id].unrated_vulnerabilities += 1
scan_result_by_channel[event.channel_id].total_vulnerabilities += 1
slack_msg = slack_vuln_info.get_slack_msg_for(event.channel_id, info_by_project)
if not slack_msg:
raise RuntimeError(
Expand Down Expand Up @@ -95,6 +97,7 @@ def handle_events(
)
elif t == SlackVulnerabilityEventType.VULN_CHANGED:
scan_result_by_channel[event.channel_id].changed_vulnerabilities += 1
scan_result_by_channel[event.channel_id].total_vulnerabilities += 1
slack_msg_id = self.__update_message_if_needed(
event.channel_id, slack_vuln_info, updated_messages, info_by_project
)
Expand All @@ -116,6 +119,8 @@ def handle_events(
self.slack_api_by_channel[event.channel_id].send_message(
message=json.dumps(vuln_chg_msg_blocks), is_block_kit_message=True, thread_id=slack_msg_id
)
elif t == SlackVulnerabilityEventType.VULN_UNCHANGED:
scan_result_by_channel[event.channel_id].total_vulnerabilities += 1
elif t == SlackVulnerabilityEventType.DEP_ADDED:
if event.finding_id not in scan_result_by_channel[event.channel_id].added_dependencies:
scan_result_by_channel[event.channel_id].added_dependencies[event.finding_id] = set()
Expand All @@ -131,6 +136,7 @@ def handle_events(
)
self.__update_message_if_needed(event.channel_id, slack_vuln_info, updated_messages, info_by_project)
elif t == SlackVulnerabilityEventType.RISK_UNKNOWN:
scan_result_by_channel[event.channel_id].unrated_vulnerabilities += 1
if event.channel_id not in slack_vuln_info.msg_info_by_channel:
raise RuntimeError(
f"could not send risk assessment reminder for channel {event.channel_id} for vuln {slack_vuln_info.vulnerability.id}"
Expand All @@ -144,10 +150,11 @@ def handle_events(
send_reminder |= ra.wants_assessment_reminder
risk_assessors.add(ra.name)
if send_reminder:
self.slack_api_by_channel[event.channel_id].send_message(
message="This finding needs risk assessment from " + ", ".join(sorted(list(risk_assessors))),
is_block_kit_message=False,
thread_id=slack_vuln_info.msg_info_by_channel[event.channel_id].message_id,
permalink = self.slack_api_by_channel[event.channel_id].get_permalink(
slack_vuln_info.msg_info_by_channel[event.channel_id].message_id
)
scan_result_by_channel[event.channel_id].add_unrated_vulnerabilities_reminder(
slack_vuln_info.vulnerability.name, permalink, risk_assessors
)
else:
raise RuntimeError(f"event has unknown type: {event}")
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

TEST_SLACK_MSG = "SLACK_MSG"
TEST_SLACK_MSG_ID = "SLACK_MSG_ID"
TEST_SLACK_MSG_PERMALINK = "SLACK_MSG_PERMALINK"

TEST_SLACK_API_SEND_MSG_CALL = call.send_message(message=TEST_SLACK_MSG, is_block_kit_message=True, thread_id=None)

Expand All @@ -36,6 +37,7 @@ def slack_api_risk_ass_msg_call(msg_id, risk_ass):
def slack_api():
slack_api = Mock()
slack_api.send_message.return_value = TEST_SLACK_MSG_ID
slack_api.get_permalink.return_value = TEST_SLACK_MSG_PERMALINK
return slack_api


Expand All @@ -48,6 +50,7 @@ def slack_store(slack_api):
def slack_vuln_info():
svi = Mock()
svi.vulnerability.id = "vid"
svi.vulnerability.name = "vname"
svi.msg_info_by_channel = {
"c1": SlackVulnerabilityMessageInfo("c1", "m1"),
"c2": SlackVulnerabilityMessageInfo("c2", "m2"),
Expand Down Expand Up @@ -137,9 +140,9 @@ def test_handle_dep_removed_event(slack_store, slack_vuln_info, slack_api):

def test_handle_risk_unknown_event(slack_store, slack_vuln_info, slack_api, info_by_project):
events = [SlackVulnerabilityEvent.risk_unknown("vid", "c1"), SlackVulnerabilityEvent.risk_unknown("vid", "c2")]
scan_res = {"c1": SlackScanResult(), "c2": SlackScanResult()}

slack_store.handle_events(events, {}, slack_vuln_info, info_by_project)
slack_store.handle_events(events, scan_res, slack_vuln_info, info_by_project)

slack_api.assert_has_calls(
[slack_api_risk_ass_msg_call("m1", "risk_ass1"), slack_api_risk_ass_msg_call("m2", "risk_ass2")]
)
assert scan_res["c1"].unrated_vulnerabilities_reminder == {"vname": (TEST_SLACK_MSG_PERMALINK, {"risk_ass1"})}
assert scan_res["c2"].unrated_vulnerabilities_reminder == {"vname": (TEST_SLACK_MSG_PERMALINK, {"risk_ass2"})}
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ def store_findings(self, repository: str, scanner: str, current_findings: List[F
# publish scan results for each channel
for channel_id, scan_result in scan_result_by_channel.items():
if scan_result.has_updates():
self.slack_api_by_channel[channel_id].send_message(
slack_msg_id = self.slack_api_by_channel[channel_id].send_message(
message=scan_result.get_slack_msg(repository, scanner), is_block_kit_message=True
)
reminders = scan_result.get_slack_thread_msgs_for_reminder()
for reminder in reminders:
self.slack_api_by_channel[channel_id].send_message(
message=reminder, thread_id=slack_msg_id, show_link_preview=False
)
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,15 @@ def handle_events(
t = event.type
if t == SlackVulnerabilityEventType.VULN_ADDED:
scan_result_by_channel[event.channel_id].new_vulnerabilities += 1
scan_result_by_channel[event.channel_id].unrated_vulnerabilities += 1
scan_result_by_channel[event.channel_id].total_vulnerabilities += 1
elif t == SlackVulnerabilityEventType.VULN_REMOVED:
scan_result_by_channel[event.channel_id].fixed_vulnerabilities += 1
elif t == SlackVulnerabilityEventType.VULN_CHANGED:
scan_result_by_channel[event.channel_id].changed_vulnerabilities += 1
scan_result_by_channel[event.channel_id].total_vulnerabilities += 1
elif t == SlackVulnerabilityEventType.VULN_UNCHANGED:
scan_result_by_channel[event.channel_id].total_vulnerabilities += 1
elif t == SlackVulnerabilityEventType.DEP_ADDED:
if event.finding_id not in scan_result_by_channel[event.channel_id].added_dependencies:
scan_result_by_channel[event.channel_id].added_dependencies[event.finding_id] = set()
Expand Down
24 changes: 23 additions & 1 deletion ci/src/dependencies/integration/slack/slack_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ def __api_request(self, url: str, data: Optional[any] = None, retry: int = 0) ->
return None

def send_message(
self, message: str, is_block_kit_message: bool = False, thread_id: Optional[str] = None
self,
message: str,
is_block_kit_message: bool = False,
thread_id: Optional[str] = None,
show_link_preview: Optional[bool] = None,
) -> Optional[str]:
if self.log_to_console:
logging.info(
Expand All @@ -70,6 +74,8 @@ def send_message(
data["text"] = message
if thread_id:
data["thread_ts"] = thread_id
if show_link_preview:
data["unfurl_links"] = show_link_preview

api_response = self.__api_request("https://slack.com/api/chat.postMessage", data, retry=3)
if api_response["ok"]:
Expand Down Expand Up @@ -221,3 +227,19 @@ def include_message(message: any) -> bool:
raise RuntimeError(
f"Slack API conversations.history returned non ok response for URL {url}: {api_response['ok']} with error: {api_response['error'] if 'error' in api_response else 'None'}"
)

def get_permalink(self, message_id: str) -> Optional[str]:
logging.info("Slack get_permalink for channel '%s' and message '%s'", self.channel_config, message_id)

# https://api.slack.com/methods/chat.getPermalink#examples
api_response = self.__api_request(
f"https://slack.com/api/chat.getPermalink?channel={self.channel_config.channel_id}&message_ts={message_id}"
)
if api_response["ok"]:
return api_response["permalink"]
else:
logging.error("Slack API chat.getPermalink failed.")
logging.debug(
"Slack API chat.getPermalink failed for channel '%s' and message '%s'.", self.channel_config, message_id
)
return None

0 comments on commit 4bc7052

Please sign in to comment.