From 5dcd1924826d52aed59a9780ca6bfce2f4292fa7 Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Tue, 23 May 2023 14:17:02 -0600 Subject: [PATCH 01/10] feat: Use the Slack Block Kit for formatting team reports --- internal/scan.go | 2 +- reporting/slack.go | 98 ++++++++++++++++++++++++++++++---------------- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/internal/scan.go b/internal/scan.go index 6d19226..c4f0f2a 100644 --- a/internal/scan.go +++ b/internal/scan.go @@ -63,7 +63,7 @@ func Scan(cmd *cobra.Command, args []string) { teamReports := reporting.CollateTeamReports(vulnsByTeam) reportTime := time.Now().Format(time.RFC1123) - summaryHeader := fmt.Sprintf("%s Dependabot Report for %s", ghOrgName, reportTime) + summaryHeader := fmt.Sprintf("%s Vulnbot Report for %s", ghOrgName, reportTime) wg := new(sync.WaitGroup) for _, reporter := range reporters { diff --git a/reporting/slack.go b/reporting/slack.go index 3c40188..2f56c69 100644 --- a/reporting/slack.go +++ b/reporting/slack.go @@ -3,6 +3,7 @@ package reporting import ( "fmt" "sort" + "strings" "sync" "time" @@ -19,6 +20,11 @@ type SlackReporter struct { client SlackClientInterface } +type SlackReport struct { + ChannelID string + Message *slack.Message +} + type SlackClientInterface interface { PostMessage(channelID string, options ...slack.MsgOption) (string, string, error) } @@ -72,20 +78,49 @@ func (s *SlackReporter) SendSummaryReport( wg *sync.WaitGroup, ) error { defer wg.Done() - summaryReport := s.buildSummaryReport(header, numRepos, report) - wg.Add(1) - go s.sendSlackMessage(s.config.Default_slack_channel, summaryReport, wg) + //summaryReport := s.buildSummaryReport(header, numRepos, report) + // wg.Add(1) + //go s.sendSlackMessage(s.config.Default_slack_channel, summaryReport, wg) return nil } +func (s *SlackReporter) buildTeamRepositoryReport( + repoName string, + repoReport VulnerabilityReport, +) *slack.SectionBlock { + var err error + var severityIcon string + severities := getSeverityReportOrder() + vulnCounts := make([]string, 0) + for _, severity := range severities { + if severityIcon == "" && repoReport.VulnsBySeverity[severity] > 0 { + severityIcon, err = config.GetIconForSeverity(severity, s.config.Severity) + if err != nil { + severityIcon = DEFAULT_SLACK_ICON + } + } + vulnCounts = append(vulnCounts, fmt.Sprintf("%2d %s", repoReport.VulnsBySeverity[severity], severity)) + } + if severityIcon == "" { + severityIcon, err = config.GetIconForSeverity("None", s.config.Severity) + if err != nil { + severityIcon = DEFAULT_SLACK_ICON + } + } + fields := []*slack.TextBlockObject{ + slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("%s *%s*", severityIcon, repoName), false, false), + slack.NewTextBlockObject(slack.MarkdownType, strings.Join(vulnCounts, " | "), false, false), + } + return slack.NewSectionBlock(nil, fields, nil) +} + func (s *SlackReporter) buildTeamReports( teamReports map[string]map[string]VulnerabilityReport, reportTime string, -) map[string]string { +) []SlackReport { log := logger.Get() - slackMessages := map[string]string{} + slackMessages := []SlackReport{} - severities := getSeverityReportOrder() for team, repos := range teamReports { teamReport := "" teamInfo, err := config.GetTeamConfigBySlug(team, s.config.Team) @@ -93,6 +128,21 @@ func (s *SlackReporter) buildTeamReports( log.Warn().Str("team", team).Msg("Skipping report for unconfigured team.") continue } + reportBlocks := []slack.Block{ + slack.NewHeaderBlock( + slack.NewTextBlockObject(slack.PlainTextType, fmt.Sprintf("%s Dependabot Report for %s", teamInfo.Name, reportTime), true, false), + ), + slack.NewDividerBlock(), + slack.NewSectionBlock( + nil, + []*slack.TextBlockObject{ + slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("*%4d Total Vulnerabilities*", repos[SUMMARY_KEY].TotalCount), false, false), + // TODO: Add a block with the breakdown by severity + }, + nil, + ), + slack.NewDividerBlock(), + } // Retrieve the list of repo names so that we can report alphabetically repoNames := maps.Keys(repos) sort.Strings(repoNames) @@ -101,31 +151,11 @@ func (s *SlackReporter) buildTeamReports( if name == SUMMARY_KEY { continue } - repoReport := fmt.Sprintf("*%s* -- ", name) - for _, severity := range severities { - count := repo.VulnsBySeverity[severity] - icon, err := config.GetIconForSeverity(severity, s.config.Severity) - if err != nil { - icon = DEFAULT_SLACK_ICON - } - repoReport += fmt.Sprintf("%s *%s*: %d ", icon, severity, count) - } - repoReport += "\n" - teamReport += repoReport + reportBlocks = append(reportBlocks, s.buildTeamRepositoryReport(name, repo)) } - teamSummary := repos[SUMMARY_KEY] - teamSummaryReport := fmt.Sprintf( - "*%s Dependabot Report for %s*\n"+ - "Affected repositories: %d\n"+ - "Total vulnerabilities: %d\n", - teamInfo.Name, - reportTime, - teamSummary.AffectedRepos, // Subtract the summary report - teamSummary.TotalCount, - ) - teamReport = teamSummaryReport + teamReport if teamInfo.Slack_channel != "" { - slackMessages[teamInfo.Slack_channel] = teamReport + message := slack.NewBlockMessage(reportBlocks...) + slackMessages = append(slackMessages, SlackReport{ChannelID: teamInfo.Slack_channel, Message: &message}) } else { log.Debug().Str("team", team).Str("teamReport", teamReport).Msg("Discarding team report because no Slack channel configured.") } @@ -141,25 +171,25 @@ func (s *SlackReporter) SendTeamReports( reportTime := s.GetReportTime() slackMessages := s.buildTeamReports(teamReports, reportTime) - for channel, message := range slackMessages { + for _, message := range slackMessages { wg.Add(1) - go s.sendSlackMessage(channel, message, wg) + go s.sendSlackMessage(message.ChannelID, slack.MsgOptionBlocks(message.Message.Blocks.BlockSet...), wg) } return nil } -func (s *SlackReporter) sendSlackMessage(channel string, message string, wg *sync.WaitGroup) { +func (s *SlackReporter) sendSlackMessage(channel string, message slack.MsgOption, wg *sync.WaitGroup) { defer wg.Done() log := logger.Get() if s.client != nil { - _, timestamp, err := s.client.PostMessage(channel, slack.MsgOptionText(message, false), slack.MsgOptionAsUser(true)) + _, timestamp, err := s.client.PostMessage(channel, message, slack.MsgOptionAsUser(true)) if err != nil { log.Error().Err(err).Msg("Failed to send Slack message.") } else { log.Info().Str("channel", channel).Str("timestamp", timestamp).Msg("Message sent to Slack.") } } else { - log.Warn().Str("message", message).Str("channel", channel).Msg("No Slack client available. Message not sent.") + log.Warn().Any("message", message).Str("channel", channel).Msg("No Slack client available. Message not sent.") } } From dbecb4bab55cb2773ab8229d6f8dc3cc7489e1fa Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Tue, 23 May 2023 15:19:39 -0600 Subject: [PATCH 02/10] fix: Pull reportTime up to top level so it's consistent across reports --- internal/scan.go | 7 ++++--- reporting/console.go | 2 ++ reporting/reporter.go | 2 ++ reporting/slack.go | 27 +++++++++++++-------------- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/internal/scan.go b/internal/scan.go index c4f0f2a..174c77e 100644 --- a/internal/scan.go +++ b/internal/scan.go @@ -53,6 +53,7 @@ func Scan(cmd *cobra.Command, args []string) { } reporters = append(reporters, &reporting.ConsoleReporter{Config: userConfig}) + reportTime := time.Now().Format(time.RFC1123) ghOrgName, allRepos := api.QueryGithubOrgVulnerabilities(ghOrgLogin, *ghClient) repositoryOwners := api.QueryGithubOrgRepositoryOwners(ghOrgLogin, *ghClient) // Count our vulnerabilities @@ -62,8 +63,7 @@ func Scan(cmd *cobra.Command, args []string) { vulnsByTeam := reporting.GroupVulnsByOwner(allRepos, repositoryOwners) teamReports := reporting.CollateTeamReports(vulnsByTeam) - reportTime := time.Now().Format(time.RFC1123) - summaryHeader := fmt.Sprintf("%s Vulnbot Report for %s", ghOrgName, reportTime) + summaryHeader := fmt.Sprintf("%s Vulnbot Report", ghOrgName) wg := new(sync.WaitGroup) for _, reporter := range reporters { @@ -72,9 +72,10 @@ func Scan(cmd *cobra.Command, args []string) { summaryHeader, len(allRepos), vulnSummary, + reportTime, wg, ) - go reporter.SendTeamReports(teamReports, wg) + go reporter.SendTeamReports(teamReports, reportTime, wg) } wg.Wait() log.Info().Msg("Done!") diff --git a/reporting/console.go b/reporting/console.go index bdbfdd9..9c5b6ff 100644 --- a/reporting/console.go +++ b/reporting/console.go @@ -41,6 +41,7 @@ func (c *ConsoleReporter) SendSummaryReport( header string, numRepos int, report VulnerabilityReport, + reportTime string, wg *sync.WaitGroup, ) error { defer wg.Done() @@ -71,6 +72,7 @@ func (c *ConsoleReporter) SendSummaryReport( // of this could be quite overwhelming. func (c *ConsoleReporter) SendTeamReports( teamReports map[string]map[string]VulnerabilityReport, + reportTime string, wg *sync.WaitGroup, ) error { defer wg.Done() diff --git a/reporting/reporter.go b/reporting/reporter.go index 8e85932..18718b0 100644 --- a/reporting/reporter.go +++ b/reporting/reporter.go @@ -7,10 +7,12 @@ type Reporter interface { header string, numRepos int, report VulnerabilityReport, + reportTime string, wg *sync.WaitGroup, ) error SendTeamReports( teamReports map[string]map[string]VulnerabilityReport, + reportTime string, wg *sync.WaitGroup, ) error } diff --git a/reporting/slack.go b/reporting/slack.go index 2f56c69..53d7f4c 100644 --- a/reporting/slack.go +++ b/reporting/slack.go @@ -5,7 +5,6 @@ import ( "sort" "strings" "sync" - "time" "github.com/underdog-tech/vulnbot/config" "github.com/underdog-tech/vulnbot/logger" @@ -16,7 +15,7 @@ import ( type SlackReporter struct { slackToken string - config config.TomlConfig + Config config.TomlConfig client SlackClientInterface } @@ -49,7 +48,7 @@ func (s *SlackReporter) buildSummaryReport( severities := getSeverityReportOrder() for _, severity := range severities { vulnCount, _ := report.VulnsBySeverity[severity] - icon, err := config.GetIconForSeverity(severity, s.config.Severity) + icon, err := config.GetIconForSeverity(severity, s.Config.Severity) if err != nil { icon = DEFAULT_SLACK_ICON } @@ -61,7 +60,7 @@ func (s *SlackReporter) buildSummaryReport( sort.Strings(ecosystems) for _, ecosystem := range ecosystems { vulnCount, _ := report.VulnsByEcosystem[ecosystem] - icon, err := config.GetIconForEcosystem(ecosystem, s.config.Ecosystem) + icon, err := config.GetIconForEcosystem(ecosystem, s.Config.Ecosystem) if err != nil { icon = DEFAULT_SLACK_ICON } @@ -75,6 +74,7 @@ func (s *SlackReporter) SendSummaryReport( header string, numRepos int, report VulnerabilityReport, + reportTime string, wg *sync.WaitGroup, ) error { defer wg.Done() @@ -94,7 +94,7 @@ func (s *SlackReporter) buildTeamRepositoryReport( vulnCounts := make([]string, 0) for _, severity := range severities { if severityIcon == "" && repoReport.VulnsBySeverity[severity] > 0 { - severityIcon, err = config.GetIconForSeverity(severity, s.config.Severity) + severityIcon, err = config.GetIconForSeverity(severity, s.Config.Severity) if err != nil { severityIcon = DEFAULT_SLACK_ICON } @@ -102,7 +102,7 @@ func (s *SlackReporter) buildTeamRepositoryReport( vulnCounts = append(vulnCounts, fmt.Sprintf("%2d %s", repoReport.VulnsBySeverity[severity], severity)) } if severityIcon == "" { - severityIcon, err = config.GetIconForSeverity("None", s.config.Severity) + severityIcon, err = config.GetIconForSeverity("None", s.Config.Severity) if err != nil { severityIcon = DEFAULT_SLACK_ICON } @@ -123,16 +123,19 @@ func (s *SlackReporter) buildTeamReports( for team, repos := range teamReports { teamReport := "" - teamInfo, err := config.GetTeamConfigBySlug(team, s.config.Team) + teamInfo, err := config.GetTeamConfigBySlug(team, s.Config.Team) if err != nil { log.Warn().Str("team", team).Msg("Skipping report for unconfigured team.") continue } reportBlocks := []slack.Block{ slack.NewHeaderBlock( - slack.NewTextBlockObject(slack.PlainTextType, fmt.Sprintf("%s Dependabot Report for %s", teamInfo.Name, reportTime), true, false), + slack.NewTextBlockObject(slack.PlainTextType, fmt.Sprintf("%s Vulnbot Report", teamInfo.Name), true, false), ), slack.NewDividerBlock(), + slack.NewContextBlock("", slack.NewTextBlockObject( + slack.MarkdownType, reportTime, false, false, + )), slack.NewSectionBlock( nil, []*slack.TextBlockObject{ @@ -165,11 +168,11 @@ func (s *SlackReporter) buildTeamReports( func (s *SlackReporter) SendTeamReports( teamReports map[string]map[string]VulnerabilityReport, + reportTime string, wg *sync.WaitGroup, ) error { defer wg.Done() - reportTime := s.GetReportTime() slackMessages := s.buildTeamReports(teamReports, reportTime) for _, message := range slackMessages { wg.Add(1) @@ -193,14 +196,10 @@ func (s *SlackReporter) sendSlackMessage(channel string, message slack.MsgOption } } -func (s *SlackReporter) GetReportTime() string { - return time.Now().Format(time.RFC1123) -} - func NewSlackReporter(config config.TomlConfig, slackToken string) (SlackReporter, error) { if slackToken == "" { return SlackReporter{}, fmt.Errorf("No Slack token was provided.") } client := slack.New(slackToken, slack.OptionDebug(true)) - return SlackReporter{config: config, client: client}, nil + return SlackReporter{Config: config, client: client}, nil } From 3cab03a102401719798ae09ba0d0f2be8fb27a62 Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Tue, 23 May 2023 16:15:06 -0600 Subject: [PATCH 03/10] feat: Change the Slack summary report to use Slack Block Kit --- reporting/slack.go | 80 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/reporting/slack.go b/reporting/slack.go index 53d7f4c..3752080 100644 --- a/reporting/slack.go +++ b/reporting/slack.go @@ -32,19 +32,38 @@ func (s *SlackReporter) buildSummaryReport( header string, numRepos int, report VulnerabilityReport, -) string { - summaryReport := fmt.Sprintf( - "*%s*\n"+ - "Total repositories: %d\n"+ - "Total vulnerabilities: %d\n"+ - "Affected repositories: %d\n", - header, - numRepos, - report.TotalCount, - report.AffectedRepos, - ) - - severityReport := "*Breakdown by Severity*\n" + reportTime string, +) slack.Message { + reportBlocks := []slack.Block{ + slack.NewHeaderBlock( + slack.NewTextBlockObject(slack.PlainTextType, header, false, false), + ), + slack.NewDividerBlock(), + slack.NewContextBlock("", slack.NewTextBlockObject( + slack.MarkdownType, reportTime, false, false, + )), + slack.NewSectionBlock( + nil, + []*slack.TextBlockObject{ + slack.NewTextBlockObject( + slack.MarkdownType, + fmt.Sprintf( + "*Total repositories:* %d\n"+ + "*Total vulnerabilities:* %d\n"+ + "*Affected repositories:* %d\n", + numRepos, + report.TotalCount, + report.AffectedRepos, + ), + false, false, + ), + }, nil, + ), + slack.NewHeaderBlock( + slack.NewTextBlockObject(slack.PlainTextType, "Breakdown by Severity", false, false), + ), + } + severities := getSeverityReportOrder() for _, severity := range severities { vulnCount, _ := report.VulnsBySeverity[severity] @@ -52,10 +71,21 @@ func (s *SlackReporter) buildSummaryReport( if err != nil { icon = DEFAULT_SLACK_ICON } - severityReport = fmt.Sprintf("%s%s %s: %d\n", severityReport, icon, severity, vulnCount) + reportBlocks = append(reportBlocks, slack.NewSectionBlock( + nil, + []*slack.TextBlockObject{ + slack.NewTextBlockObject( + slack.MarkdownType, + fmt.Sprintf("%s *%s:* %d", icon, severity, vulnCount), + false, false, + ), + }, nil, + )) } - ecosystemReport := "*Breakdown by Ecosystem*\n" + reportBlocks = append(reportBlocks, slack.NewHeaderBlock( + slack.NewTextBlockObject(slack.PlainTextType, "Breakdown by Ecosystem", false, false), + )) ecosystems := maps.Keys(report.VulnsByEcosystem) sort.Strings(ecosystems) for _, ecosystem := range ecosystems { @@ -64,10 +94,18 @@ func (s *SlackReporter) buildSummaryReport( if err != nil { icon = DEFAULT_SLACK_ICON } - ecosystemReport = fmt.Sprintf("%s%s %s: %d\n", ecosystemReport, icon, ecosystem, vulnCount) + reportBlocks = append(reportBlocks, slack.NewSectionBlock( + nil, + []*slack.TextBlockObject{ + slack.NewTextBlockObject( + slack.MarkdownType, + fmt.Sprintf("%s *%s:* %d", icon, ecosystem, vulnCount), + false, false, + ), + }, nil, + )) } - summaryReport = fmt.Sprintf("%s\n%s\n%s", summaryReport, severityReport, ecosystemReport) - return summaryReport + return slack.NewBlockMessage(reportBlocks...) } func (s *SlackReporter) SendSummaryReport( @@ -78,9 +116,9 @@ func (s *SlackReporter) SendSummaryReport( wg *sync.WaitGroup, ) error { defer wg.Done() - //summaryReport := s.buildSummaryReport(header, numRepos, report) - // wg.Add(1) - //go s.sendSlackMessage(s.config.Default_slack_channel, summaryReport, wg) + summaryReport := s.buildSummaryReport(header, numRepos, report, reportTime) + wg.Add(1) + go s.sendSlackMessage(s.Config.Default_slack_channel, slack.MsgOptionBlocks(summaryReport.Blocks.BlockSet...), wg) return nil } From 03e44c0f95dad46c1bed5c40b80f8196029ee6e6 Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Wed, 24 May 2023 17:26:43 -0600 Subject: [PATCH 04/10] fix: Use the new call signature for SendSummaryReport --- reporting/console_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reporting/console_test.go b/reporting/console_test.go index a707014..666ccbe 100644 --- a/reporting/console_test.go +++ b/reporting/console_test.go @@ -55,7 +55,7 @@ Affected repositories: 2 wg := new(sync.WaitGroup) wg.Add(1) - reporter.SendSummaryReport("OrgName Dependabot Report for now", 13, report, wg) + reporter.SendSummaryReport("OrgName Dependabot Report for now", 13, report, "now", wg) writer.Close() written, _ := ioutil.ReadAll(reader) os.Stdout = origStdout From 0214d68f9276f61b0cbe36119c95a0d186672b9a Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Wed, 24 May 2023 17:27:46 -0600 Subject: [PATCH 05/10] fix: Properly test and fix up the new *SummaryReport code --- reporting/slack.go | 56 ++++++++--------- reporting/slack_test.go | 136 +++++++++++++++++++++++++++++++--------- 2 files changed, 133 insertions(+), 59 deletions(-) diff --git a/reporting/slack.go b/reporting/slack.go index 3752080..1a32f15 100644 --- a/reporting/slack.go +++ b/reporting/slack.go @@ -40,24 +40,22 @@ func (s *SlackReporter) buildSummaryReport( ), slack.NewDividerBlock(), slack.NewContextBlock("", slack.NewTextBlockObject( - slack.MarkdownType, reportTime, false, false, + slack.PlainTextType, reportTime, false, false, )), slack.NewSectionBlock( - nil, - []*slack.TextBlockObject{ - slack.NewTextBlockObject( - slack.MarkdownType, - fmt.Sprintf( - "*Total repositories:* %d\n"+ - "*Total vulnerabilities:* %d\n"+ - "*Affected repositories:* %d\n", - numRepos, - report.TotalCount, - report.AffectedRepos, - ), - false, false, + slack.NewTextBlockObject( + slack.MarkdownType, + fmt.Sprintf( + "*Total repositories:* %d\n"+ + "*Total vulnerabilities:* %d\n"+ + "*Affected repositories:* %d\n", + numRepos, + report.TotalCount, + report.AffectedRepos, ), - }, nil, + false, false, + ), + nil, nil, ), slack.NewHeaderBlock( slack.NewTextBlockObject(slack.PlainTextType, "Breakdown by Severity", false, false), @@ -72,14 +70,12 @@ func (s *SlackReporter) buildSummaryReport( icon = DEFAULT_SLACK_ICON } reportBlocks = append(reportBlocks, slack.NewSectionBlock( - nil, - []*slack.TextBlockObject{ - slack.NewTextBlockObject( - slack.MarkdownType, - fmt.Sprintf("%s *%s:* %d", icon, severity, vulnCount), - false, false, - ), - }, nil, + slack.NewTextBlockObject( + slack.MarkdownType, + fmt.Sprintf("%s *%s:* %d", icon, severity, vulnCount), + false, false, + ), + nil, nil, )) } @@ -95,14 +91,12 @@ func (s *SlackReporter) buildSummaryReport( icon = DEFAULT_SLACK_ICON } reportBlocks = append(reportBlocks, slack.NewSectionBlock( - nil, - []*slack.TextBlockObject{ - slack.NewTextBlockObject( - slack.MarkdownType, - fmt.Sprintf("%s *%s:* %d", icon, ecosystem, vulnCount), - false, false, - ), - }, nil, + slack.NewTextBlockObject( + slack.MarkdownType, + fmt.Sprintf("%s *%s:* %d", icon, ecosystem, vulnCount), + false, false, + ), + nil, nil, )) } return slack.NewBlockMessage(reportBlocks...) diff --git a/reporting/slack_test.go b/reporting/slack_test.go index 3a5587e..307dfa2 100644 --- a/reporting/slack_test.go +++ b/reporting/slack_test.go @@ -1,6 +1,7 @@ package reporting import ( + "encoding/json" "fmt" "sync" "testing" @@ -23,13 +24,13 @@ func (m *MockSlackClient) PostMessage(channelID string, options ...slack.MsgOpti func TestSendSlackMessagesSuccess(t *testing.T) { mockClient := new(MockSlackClient) config := config.TomlConfig{} - reporter := SlackReporter{config: config, client: mockClient} + reporter := SlackReporter{Config: config, client: mockClient} mockClient.On("PostMessage", "channel", mock.Anything, mock.Anything).Return("", "", nil).Once() wg := new(sync.WaitGroup) wg.Add(1) - reporter.sendSlackMessage("channel", "message", wg) + reporter.sendSlackMessage("channel", slack.MsgOptionText("message", false), wg) mockClient.AssertExpectations(t) } @@ -37,13 +38,13 @@ func TestSendSlackMessagesSuccess(t *testing.T) { func TestSendSlackMessagesError(t *testing.T) { mockClient := new(MockSlackClient) config := config.TomlConfig{} - reporter := SlackReporter{config: config, client: mockClient} + reporter := SlackReporter{Config: config, client: mockClient} mockClient.On("PostMessage", "channel", mock.Anything, mock.Anything).Return("", "", fmt.Errorf("Failed to send Slack message")).Once() wg := new(sync.WaitGroup) wg.Add(1) - reporter.sendSlackMessage("channel", "message", wg) + reporter.sendSlackMessage("channel", slack.MsgOptionText("message", false), wg) mockClient.AssertExpectations(t) } @@ -52,11 +53,11 @@ func TestSendSlackMessagesError(t *testing.T) { func TestSendSlackMessageWithNoClient(t *testing.T) { config := config.TomlConfig{} // Create a report instance with NO client - reporter := SlackReporter{config: config} + reporter := SlackReporter{Config: config} wg := new(sync.WaitGroup) wg.Add(1) - reporter.sendSlackMessage("channel", "message", wg) + reporter.sendSlackMessage("channel", slack.MsgOptionText("message", false), wg) } func TestIsSlackTokenMissing(t *testing.T) { @@ -82,37 +83,116 @@ func TestBuildSummaryReport(t *testing.T) { report.VulnsBySeverity["High"] = 10 report.VulnsBySeverity["Moderate"] = 10 report.VulnsBySeverity["Low"] = 12 - expected := `*OrgName Dependabot Report for now* -Total repositories: 13 -Total vulnerabilities: 42 -Affected repositories: 2 - -*Breakdown by Severity* - Critical: 10 - High: 10 - Moderate: 10 - Low: 12 -*Breakdown by Ecosystem* - Npm: 40 - Pip: 2 -` - - actual := reporter.buildSummaryReport("OrgName Dependabot Report for now", 13, report) - assert.Equal(t, expected, actual) + expected_data := map[string]interface{}{ + "replace_original": false, + "delete_original": false, + "metadata": map[string]interface{}{ + "event_payload": nil, + "event_type": "", + }, + "blocks": []map[string]interface{}{ + { + "type": "header", + "text": map[string]interface{}{ + "type": "plain_text", + "text": "OrgName Vulnbot Report", + }, + }, + { + "type": "divider", + }, + { + "type": "context", + "elements": []map[string]interface{}{ + { + "type": "plain_text", + "text": "now", + }, + }, + }, + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": "*Total repositories:* 13\n*Total vulnerabilities:* 42\n*Affected repositories:* 2\n", + }, + }, + { + "type": "header", + "text": map[string]interface{}{ + "type": "plain_text", + "text": "Breakdown by Severity", + }, + }, + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": " *Critical:* 10", + }, + }, + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": " *High:* 10", + }, + }, + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": " *Moderate:* 10", + }, + }, + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": " *Low:* 12", + }, + }, + { + "type": "header", + "text": map[string]interface{}{ + "type": "plain_text", + "text": "Breakdown by Ecosystem", + }, + }, + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": " *Npm:* 40", + }, + }, + { + "type": "section", + "text": map[string]interface{}{ + "type": "mrkdwn", + "text": " *Pip:* 2", + }, + }, + }, + } + expected, _ := json.Marshal(expected_data) + summary := reporter.buildSummaryReport("OrgName Vulnbot Report", 13, report, "now") + actual, _ := json.Marshal(summary) + assert.JSONEq(t, string(expected), string(actual)) } func TestSendSummaryReportSendsSingleMessage(t *testing.T) { mockClient := new(MockSlackClient) config := config.TomlConfig{Default_slack_channel: "channel"} - reporter := SlackReporter{config: config, client: mockClient} + reporter := SlackReporter{Config: config, client: mockClient} report := NewVulnerabilityReport() mockClient.On("PostMessage", "channel", mock.Anything, mock.Anything).Return("", "", nil).Once() wg := new(sync.WaitGroup) wg.Add(1) - reporter.SendSummaryReport("Foo", 1, report, wg) + reporter.SendSummaryReport("Foo", 1, report, "now", wg) wg.Wait() mockClient.AssertExpectations(t) @@ -131,7 +211,7 @@ func TestBuildTeamReports(t *testing.T) { {Name: "baz", Github_slug: "baz"}, }, } - reporter := SlackReporter{config: config} + reporter := SlackReporter{Config: config} repo1Report := NewVulnerabilityReport() repo1Report.VulnsByEcosystem["Pip"] = 10 @@ -195,7 +275,7 @@ func TestSendTeamReportsSendsMessagePerTeam(t *testing.T) { }, } mockClient := new(MockSlackClient) - reporter := SlackReporter{config: config, client: mockClient} + reporter := SlackReporter{Config: config, client: mockClient} repo1Report := NewVulnerabilityReport() repo2Report := NewVulnerabilityReport() teamReports := map[string]map[string]VulnerabilityReport{ @@ -212,7 +292,7 @@ func TestSendTeamReportsSendsMessagePerTeam(t *testing.T) { wg := new(sync.WaitGroup) wg.Add(1) - reporter.SendTeamReports(teamReports, wg) + reporter.SendTeamReports(teamReports, "now", wg) wg.Wait() mockClient.AssertExpectations(t) From 4e9d1d9beee45140158f42e638068b7df423ce9a Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Wed, 24 May 2023 17:30:13 -0600 Subject: [PATCH 06/10] fix: Rename the tests to be obvious about which reporter they are testing --- reporting/console_test.go | 2 +- reporting/slack_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/reporting/console_test.go b/reporting/console_test.go index 666ccbe..bdc79db 100644 --- a/reporting/console_test.go +++ b/reporting/console_test.go @@ -12,7 +12,7 @@ import ( "github.com/underdog-tech/vulnbot/config" ) -func TestSendSummaryReport(t *testing.T) { +func TestSendConsoleSummaryReport(t *testing.T) { origStdout := os.Stdout reader, writer, _ := os.Pipe() os.Stdout = writer diff --git a/reporting/slack_test.go b/reporting/slack_test.go index 307dfa2..0f078d4 100644 --- a/reporting/slack_test.go +++ b/reporting/slack_test.go @@ -70,7 +70,7 @@ func TestSlackTokenIsNotMissing(t *testing.T) { assert.NoError(t, err) } -func TestBuildSummaryReport(t *testing.T) { +func TestBuildSlackSummaryReport(t *testing.T) { reporter := SlackReporter{} // Construct a full summary report so that we can verify the output @@ -182,7 +182,7 @@ func TestBuildSummaryReport(t *testing.T) { assert.JSONEq(t, string(expected), string(actual)) } -func TestSendSummaryReportSendsSingleMessage(t *testing.T) { +func TestSendSlackSummaryReportSendsSingleMessage(t *testing.T) { mockClient := new(MockSlackClient) config := config.TomlConfig{Default_slack_channel: "channel"} reporter := SlackReporter{Config: config, client: mockClient} @@ -201,7 +201,7 @@ func TestSendSummaryReportSendsSingleMessage(t *testing.T) { // This test is very long because it is attempting to verify we are generating // the proper message for multiple teams, which requires both a lot of input, as // well as a lot of output. -func TestBuildTeamReports(t *testing.T) { +func TestBuildSlackTeamReports(t *testing.T) { // We want to provide config that contains 2 teams with channels, and one without. // There will also be a report create for a team not included in this map. config := config.TomlConfig{ @@ -264,7 +264,7 @@ Total vulnerabilities: 10 assert.Equal(t, expected, actual) } -func TestSendTeamReportsSendsMessagePerTeam(t *testing.T) { +func TestSendSlackTeamReportsSendsMessagePerTeam(t *testing.T) { // We want to provide config that contains 2 teams with channels, and one without. // There will also be a report create for a team not included in this map. config := config.TomlConfig{ From b027972c6781c096890ba6d378e230b89d08bc3e Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Wed, 24 May 2023 20:15:32 -0600 Subject: [PATCH 07/10] test: Test the new repository report builder --- reporting/slack_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/reporting/slack_test.go b/reporting/slack_test.go index 0f078d4..6ff1784 100644 --- a/reporting/slack_test.go +++ b/reporting/slack_test.go @@ -264,6 +264,35 @@ Total vulnerabilities: 10 assert.Equal(t, expected, actual) } +func TestBuildSlackTeamRepositoryReport(t *testing.T) { + reporter := SlackReporter{} + + report := NewVulnerabilityReport() + report.VulnsByEcosystem["Pip"] = 15 + report.VulnsBySeverity["Critical"] = 2 + report.VulnsBySeverity["High"] = 3 + report.VulnsBySeverity["Low"] = 10 + + expected_data := map[string]interface{}{ + "type": "section", + "fields": []map[string]interface{}{ + { + "type": "mrkdwn", + "text": " *foo*", + }, + { + "type": "mrkdwn", + "text": " 2 Critical | 3 High | 0 Moderate | 10 Low", + }, + }, + } + + expected, _ := json.Marshal(expected_data) + repoReport := reporter.buildTeamRepositoryReport("foo", report) + actual, _ := json.Marshal(repoReport) + assert.JSONEq(t, string(expected), string(actual)) +} + func TestSendSlackTeamReportsSendsMessagePerTeam(t *testing.T) { // We want to provide config that contains 2 teams with channels, and one without. // There will also be a report create for a team not included in this map. From f984ab775c640b45da2dfab9c02d2839999d3804 Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Wed, 24 May 2023 20:34:23 -0600 Subject: [PATCH 08/10] fix: Split out buildTeamReport for modularity/testability --- reporting/slack.go | 98 +++++++++++++++++++++++------------------ reporting/slack_test.go | 2 +- 2 files changed, 55 insertions(+), 45 deletions(-) diff --git a/reporting/slack.go b/reporting/slack.go index 1a32f15..b7fc056 100644 --- a/reporting/slack.go +++ b/reporting/slack.go @@ -146,53 +146,63 @@ func (s *SlackReporter) buildTeamRepositoryReport( return slack.NewSectionBlock(nil, fields, nil) } -func (s *SlackReporter) buildTeamReports( - teamReports map[string]map[string]VulnerabilityReport, +func (s *SlackReporter) buildTeamReport( + teamID string, + repos map[string]VulnerabilityReport, reportTime string, -) []SlackReport { +) *SlackReport { log := logger.Get() - slackMessages := []SlackReport{} - - for team, repos := range teamReports { - teamReport := "" - teamInfo, err := config.GetTeamConfigBySlug(team, s.Config.Team) - if err != nil { - log.Warn().Str("team", team).Msg("Skipping report for unconfigured team.") + teamInfo, err := config.GetTeamConfigBySlug(teamID, s.Config.Team) + if err != nil { + log.Warn().Err(err).Str("team", teamID).Msg("Skipping report for unconfigured team.") + return nil + } + if teamInfo.Slack_channel == "" { + log.Debug().Str("team", teamID).Any("repos", repos).Msg("Skipping report since Slack channel is not configured.") + return nil + } + reportBlocks := []slack.Block{ + slack.NewHeaderBlock( + slack.NewTextBlockObject(slack.PlainTextType, fmt.Sprintf("%s Vulnbot Report", teamInfo.Name), true, false), + ), + slack.NewDividerBlock(), + slack.NewContextBlock("", slack.NewTextBlockObject( + slack.MarkdownType, reportTime, false, false, + )), + slack.NewSectionBlock( + nil, + []*slack.TextBlockObject{ + slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("*%4d Total Vulnerabilities*", repos[SUMMARY_KEY].TotalCount), false, false), + // TODO: Add a block with the breakdown by severity + }, + nil, + ), + slack.NewDividerBlock(), + } + // Retrieve the list of repo names so that we can report alphabetically + repoNames := maps.Keys(repos) + sort.Strings(repoNames) + for _, name := range repoNames { + repo := repos[name] + if name == SUMMARY_KEY { continue } - reportBlocks := []slack.Block{ - slack.NewHeaderBlock( - slack.NewTextBlockObject(slack.PlainTextType, fmt.Sprintf("%s Vulnbot Report", teamInfo.Name), true, false), - ), - slack.NewDividerBlock(), - slack.NewContextBlock("", slack.NewTextBlockObject( - slack.MarkdownType, reportTime, false, false, - )), - slack.NewSectionBlock( - nil, - []*slack.TextBlockObject{ - slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("*%4d Total Vulnerabilities*", repos[SUMMARY_KEY].TotalCount), false, false), - // TODO: Add a block with the breakdown by severity - }, - nil, - ), - slack.NewDividerBlock(), - } - // Retrieve the list of repo names so that we can report alphabetically - repoNames := maps.Keys(repos) - sort.Strings(repoNames) - for _, name := range repoNames { - repo := repos[name] - if name == SUMMARY_KEY { - continue - } - reportBlocks = append(reportBlocks, s.buildTeamRepositoryReport(name, repo)) - } - if teamInfo.Slack_channel != "" { - message := slack.NewBlockMessage(reportBlocks...) - slackMessages = append(slackMessages, SlackReport{ChannelID: teamInfo.Slack_channel, Message: &message}) - } else { - log.Debug().Str("team", team).Str("teamReport", teamReport).Msg("Discarding team report because no Slack channel configured.") + reportBlocks = append(reportBlocks, s.buildTeamRepositoryReport(name, repo)) + } + message := slack.NewBlockMessage(reportBlocks...) + return &SlackReport{ChannelID: teamInfo.Slack_channel, Message: &message} +} + +func (s *SlackReporter) buildAllTeamReports( + teamReports map[string]map[string]VulnerabilityReport, + reportTime string, +) []*SlackReport { + slackMessages := []*SlackReport{} + + for team, repos := range teamReports { + report := s.buildTeamReport(team, repos, reportTime) + if report != nil { + slackMessages = append(slackMessages, report) } } return slackMessages @@ -205,7 +215,7 @@ func (s *SlackReporter) SendTeamReports( ) error { defer wg.Done() - slackMessages := s.buildTeamReports(teamReports, reportTime) + slackMessages := s.buildAllTeamReports(teamReports, reportTime) for _, message := range slackMessages { wg.Add(1) go s.sendSlackMessage(message.ChannelID, slack.MsgOptionBlocks(message.Message.Blocks.BlockSet...), wg) diff --git a/reporting/slack_test.go b/reporting/slack_test.go index 6ff1784..a3ec034 100644 --- a/reporting/slack_test.go +++ b/reporting/slack_test.go @@ -260,7 +260,7 @@ Total vulnerabilities: 10 *repo1* -- *Critical*: 0 *High*: 0 *Moderate*: 0 *Low*: 10 `, } - actual := reporter.buildTeamReports(teamReports, "now") + actual := reporter.buildAllTeamReports(teamReports, "now") assert.Equal(t, expected, actual) } From a00dcd16b800f12264ed6025ac4b1ec6813f99d1 Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Thu, 25 May 2023 15:02:33 -0600 Subject: [PATCH 09/10] fix: Test the new buildTeamReport function --- reporting/slack.go | 4 +- reporting/slack_test.go | 91 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/reporting/slack.go b/reporting/slack.go index b7fc056..c0bd333 100644 --- a/reporting/slack.go +++ b/reporting/slack.go @@ -163,11 +163,11 @@ func (s *SlackReporter) buildTeamReport( } reportBlocks := []slack.Block{ slack.NewHeaderBlock( - slack.NewTextBlockObject(slack.PlainTextType, fmt.Sprintf("%s Vulnbot Report", teamInfo.Name), true, false), + slack.NewTextBlockObject(slack.PlainTextType, fmt.Sprintf("%s Vulnbot Report", teamInfo.Name), false, false), ), slack.NewDividerBlock(), slack.NewContextBlock("", slack.NewTextBlockObject( - slack.MarkdownType, reportTime, false, false, + slack.PlainTextType, reportTime, false, false, )), slack.NewSectionBlock( nil, diff --git a/reporting/slack_test.go b/reporting/slack_test.go index a3ec034..50c0683 100644 --- a/reporting/slack_test.go +++ b/reporting/slack_test.go @@ -293,6 +293,97 @@ func TestBuildSlackTeamRepositoryReport(t *testing.T) { assert.JSONEq(t, string(expected), string(actual)) } +func TestBuildSlackTeamReport(t *testing.T) { + config := config.TomlConfig{ + Team: []config.TeamConfig{ + {Name: "TeamName", Slack_channel: "team-foo", Github_slug: "TeamName"}, + }, + } + reporter := SlackReporter{Config: config} + + repo1Report := NewVulnerabilityReport() + repo1Report.VulnsByEcosystem["Pip"] = 10 + repo1Report.VulnsBySeverity["Low"] = 10 + + repo2Report := NewVulnerabilityReport() + repo2Report.VulnsByEcosystem["Pip"] = 5 + repo2Report.VulnsBySeverity["Critical"] = 1 + repo2Report.VulnsBySeverity["Moderate"] = 4 + + summaryReport := NewVulnerabilityReport() + summaryReport.AffectedRepos = 2 + summaryReport.TotalCount = 15 + + repoReports := map[string]VulnerabilityReport{ + "repo1": repo1Report, + "repo2": repo2Report, + SUMMARY_KEY: summaryReport, + } + + // `buildTeamRepositoryReport` is tested elsewhere, so no need to manually + // build up its output here. + // We have to marshal and then unmarshal to get then into JSON format then + // back into a Go data structure. + var repo1Expected, repo2Expected map[string]interface{} + repo1Data := reporter.buildTeamRepositoryReport("repo1", repo1Report) + repo2Data := reporter.buildTeamRepositoryReport("repo2", repo2Report) + repo1ExpectedBytes, _ := json.Marshal(repo1Data) + json.Unmarshal(repo1ExpectedBytes, &repo1Expected) + repo2ExpectedBytes, _ := json.Marshal(repo2Data) + json.Unmarshal(repo2ExpectedBytes, &repo2Expected) + + expectedData := map[string]interface{}{ + "replace_original": false, + "delete_original": false, + "metadata": map[string]interface{}{ + "event_payload": nil, + "event_type": "", + }, + "blocks": []map[string]interface{}{ + { + "type": "header", + "text": map[string]interface{}{ + "type": "plain_text", + "text": "TeamName Vulnbot Report", + }, + }, + { + "type": "divider", + }, + { + "type": "context", + "elements": []map[string]interface{}{ + { + "type": "plain_text", + "text": "now", + }, + }, + }, + { + "type": "section", + "fields": []map[string]interface{}{ + { + "type": "mrkdwn", + "text": "* 15 Total Vulnerabilities*", + }, + }, + }, + { + "type": "divider", + }, + repo1Expected, + repo2Expected, + }, + } + expected, _ := json.Marshal(expectedData) + teamReport := reporter.buildTeamReport("TeamName", repoReports, "now") + actual, _ := json.Marshal(teamReport.Message) + // Ensure the Slack Blocks match up + assert.JSONEq(t, string(expected), string(actual)) + // Ensure it's set for the right channel. + assert.Equal(t, "team-foo", teamReport.ChannelID) +} + func TestSendSlackTeamReportsSendsMessagePerTeam(t *testing.T) { // We want to provide config that contains 2 teams with channels, and one without. // There will also be a report create for a team not included in this map. From 49fe77d07026440fc83332520ef3c1ff67f0adc1 Mon Sep 17 00:00:00 2001 From: Joey Wilhelm Date: Thu, 25 May 2023 15:04:47 -0600 Subject: [PATCH 10/10] fix: Remove the outdated test for BuildTeamReports --- reporting/slack_test.go | 66 ----------------------------------------- 1 file changed, 66 deletions(-) diff --git a/reporting/slack_test.go b/reporting/slack_test.go index 50c0683..38e153f 100644 --- a/reporting/slack_test.go +++ b/reporting/slack_test.go @@ -198,72 +198,6 @@ func TestSendSlackSummaryReportSendsSingleMessage(t *testing.T) { mockClient.AssertExpectations(t) } -// This test is very long because it is attempting to verify we are generating -// the proper message for multiple teams, which requires both a lot of input, as -// well as a lot of output. -func TestBuildSlackTeamReports(t *testing.T) { - // We want to provide config that contains 2 teams with channels, and one without. - // There will also be a report create for a team not included in this map. - config := config.TomlConfig{ - Team: []config.TeamConfig{ - {Name: "foo", Slack_channel: "team-foo", Github_slug: "foo"}, - {Name: "bar", Slack_channel: "team-bar", Github_slug: "bar"}, - {Name: "baz", Github_slug: "baz"}, - }, - } - reporter := SlackReporter{Config: config} - - repo1Report := NewVulnerabilityReport() - repo1Report.VulnsByEcosystem["Pip"] = 10 - repo1Report.VulnsBySeverity["Low"] = 10 - - repo2Report := NewVulnerabilityReport() - repo2Report.VulnsByEcosystem["Pip"] = 5 - repo2Report.VulnsBySeverity["Critical"] = 1 - repo2Report.VulnsBySeverity["Moderate"] = 4 - - fooSummary := NewVulnerabilityReport() - fooSummary.AffectedRepos = 2 - fooSummary.TotalCount = 15 - - barSummary := NewVulnerabilityReport() - barSummary.AffectedRepos = 1 - barSummary.TotalCount = 10 - teamReports := map[string]map[string]VulnerabilityReport{ - "foo": { - "repo1": repo1Report, - "repo2": repo2Report, - SUMMARY_KEY: fooSummary, - }, - "bar": { - "repo1": repo1Report, - SUMMARY_KEY: barSummary, - }, - "baz": { - "repo2": repo2Report, - }, - "blah": { - "repo1": repo1Report, - "repo2": repo2Report, - }, - } - expected := map[string]string{ - "team-foo": `*foo Dependabot Report for now* -Affected repositories: 2 -Total vulnerabilities: 15 -*repo1* -- *Critical*: 0 *High*: 0 *Moderate*: 0 *Low*: 10 -*repo2* -- *Critical*: 1 *High*: 0 *Moderate*: 4 *Low*: 0 -`, - "team-bar": `*bar Dependabot Report for now* -Affected repositories: 1 -Total vulnerabilities: 10 -*repo1* -- *Critical*: 0 *High*: 0 *Moderate*: 0 *Low*: 10 -`, - } - actual := reporter.buildAllTeamReports(teamReports, "now") - assert.Equal(t, expected, actual) -} - func TestBuildSlackTeamRepositoryReport(t *testing.T) { reporter := SlackReporter{}