297 lines
8.5 KiB
Go
297 lines
8.5 KiB
Go
package webhooks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
)
|
|
|
|
// maskURL masks a URL for display (shows scheme + host, hides path/query)
|
|
func maskURL(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
if len(rawURL) > 30 {
|
|
return rawURL[:30] + "***"
|
|
}
|
|
return rawURL
|
|
}
|
|
masked := u.Scheme + "://" + u.Host
|
|
if u.Path != "" && u.Path != "/" {
|
|
masked += "/***"
|
|
}
|
|
return masked
|
|
}
|
|
|
|
// isDiscordWebhook checks if the URL points to a Discord webhook endpoint
|
|
func isDiscordWebhook(rawURL string) bool {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com")
|
|
}
|
|
|
|
// isSlackWebhook checks if the URL points to a Slack webhook endpoint
|
|
func isSlackWebhook(rawURL string) bool {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return u.Host == "hooks.slack.com"
|
|
}
|
|
|
|
// webhookSeverityColor returns a color int based on the highest severity present
|
|
func webhookSeverityColor(vulns WebhookVulnCounts) int {
|
|
switch {
|
|
case vulns.Critical > 0:
|
|
return 0xED4245 // red
|
|
case vulns.High > 0:
|
|
return 0xFFA500 // orange
|
|
case vulns.Medium > 0:
|
|
return 0xFEE75C // yellow
|
|
case vulns.Low > 0:
|
|
return 0x57F287 // green
|
|
default:
|
|
return 0x95A5A6 // grey
|
|
}
|
|
}
|
|
|
|
// webhookSeverityHex returns a hex color string (e.g., "#ED4245")
|
|
func webhookSeverityHex(vulns WebhookVulnCounts) string {
|
|
return fmt.Sprintf("#%06X", webhookSeverityColor(vulns))
|
|
}
|
|
|
|
// formatVulnDescription builds a vulnerability summary with colored square emojis
|
|
func formatVulnDescription(v WebhookVulnCounts, digest string) string {
|
|
var lines []string
|
|
|
|
if len(digest) > 19 {
|
|
lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"..."))
|
|
}
|
|
|
|
if v.Total == 0 {
|
|
lines = append(lines, "🟩 No vulnerabilities found")
|
|
} else {
|
|
if v.Critical > 0 {
|
|
lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical))
|
|
}
|
|
if v.High > 0 {
|
|
lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High))
|
|
}
|
|
if v.Medium > 0 {
|
|
lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium))
|
|
}
|
|
if v.Low > 0 {
|
|
lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low))
|
|
}
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// formatPlatformPayload detects the payload type and formats for Discord or Slack
|
|
func formatPlatformPayload(payload []byte, webhookURL string, meta atproto.AppviewMetadata) ([]byte, error) {
|
|
// Detect push vs scan payload by checking for push_data key
|
|
var probe struct {
|
|
PushData json.RawMessage `json:"push_data"`
|
|
}
|
|
if err := json.Unmarshal(payload, &probe); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if probe.PushData != nil {
|
|
var p PushWebhookPayload
|
|
if err := json.Unmarshal(payload, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
if isDiscordWebhook(webhookURL) {
|
|
return formatDiscordPushPayload(p, meta)
|
|
}
|
|
return formatSlackPushPayload(p, meta)
|
|
}
|
|
|
|
var p WebhookPayload
|
|
if err := json.Unmarshal(payload, &p); err != nil {
|
|
return nil, err
|
|
}
|
|
if isDiscordWebhook(webhookURL) {
|
|
return formatDiscordPayload(p, meta)
|
|
}
|
|
return formatSlackPayload(p, meta)
|
|
}
|
|
|
|
// formatDiscordPushPayload wraps a push webhook payload in Discord's embed format
|
|
func formatDiscordPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
|
appviewURL := meta.BaseURL
|
|
|
|
title := p.Repository.Name
|
|
if p.PushData.Tag != "" {
|
|
title += ":" + p.PushData.Tag
|
|
}
|
|
|
|
digest := p.PushData.Digest
|
|
if len(digest) > 19 {
|
|
digest = digest[:19] + "..."
|
|
}
|
|
description := fmt.Sprintf("Pushed by **%s**\nDigest: `%s`", p.PushData.Pusher, digest)
|
|
|
|
embed := map[string]any{
|
|
"title": title,
|
|
"url": p.Repository.RepoURL,
|
|
"description": description,
|
|
"color": 0x5865F2, // blurple
|
|
"footer": map[string]string{
|
|
"text": meta.ClientShortName,
|
|
"icon_url": meta.FaviconURL,
|
|
},
|
|
"timestamp": p.PushData.PushedAt,
|
|
}
|
|
|
|
embed["author"] = map[string]string{
|
|
"name": p.PushData.Pusher,
|
|
"url": appviewURL + "/u/" + p.PushData.Pusher,
|
|
}
|
|
embed["image"] = map[string]string{
|
|
"url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name),
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"username": meta.ClientShortName,
|
|
"avatar_url": meta.FaviconURL,
|
|
"embeds": []any{embed},
|
|
}
|
|
return json.Marshal(payload)
|
|
}
|
|
|
|
// formatSlackPushPayload wraps a push webhook payload in Slack's message format
|
|
func formatSlackPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
|
appviewURL := meta.BaseURL
|
|
|
|
title := p.Repository.Name
|
|
if p.PushData.Tag != "" {
|
|
title += ":" + p.PushData.Tag
|
|
}
|
|
|
|
fallback := fmt.Sprintf("%s pushed %s", p.PushData.Pusher, title)
|
|
|
|
digest := p.PushData.Digest
|
|
if len(digest) > 19 {
|
|
digest = digest[:19] + "..."
|
|
}
|
|
description := fmt.Sprintf("Pushed by *%s*\nDigest: `%s`", p.PushData.Pusher, digest)
|
|
|
|
attachment := map[string]any{
|
|
"fallback": fallback,
|
|
"color": "#5865F2",
|
|
"title": title,
|
|
"title_link": p.Repository.RepoURL,
|
|
"text": description,
|
|
"footer": meta.ClientShortName,
|
|
"footer_icon": meta.FaviconURL,
|
|
"ts": p.PushData.PushedAt,
|
|
"author_name": p.PushData.Pusher,
|
|
"author_link": appviewURL + "/u/" + p.PushData.Pusher,
|
|
"image_url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name),
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"text": fallback,
|
|
"attachments": []any{attachment},
|
|
}
|
|
return json.Marshal(payload)
|
|
}
|
|
|
|
// formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format
|
|
func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
|
appviewURL := meta.BaseURL
|
|
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
|
|
|
|
description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest)
|
|
|
|
// Add previous counts for scan:changed
|
|
if p.Trigger == "scan:changed" && p.Previous != nil {
|
|
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
|
|
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
|
|
}
|
|
|
|
embed := map[string]any{
|
|
"title": title,
|
|
"url": appviewURL,
|
|
"description": description,
|
|
"color": webhookSeverityColor(p.Scan.Vulnerabilities),
|
|
"footer": map[string]string{
|
|
"text": meta.ClientShortName,
|
|
"icon_url": meta.FaviconURL,
|
|
},
|
|
"timestamp": p.Scan.ScannedAt,
|
|
}
|
|
|
|
// Add author, repo link, and OG image when handle is available
|
|
if p.Manifest.UserHandle != "" {
|
|
embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
|
embed["author"] = map[string]string{
|
|
"name": p.Manifest.UserHandle,
|
|
"url": appviewURL + "/u/" + p.Manifest.UserHandle,
|
|
}
|
|
embed["image"] = map[string]string{
|
|
"url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository),
|
|
}
|
|
} else {
|
|
embed["image"] = map[string]string{
|
|
"url": appviewURL + "/og/home",
|
|
}
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"username": meta.ClientShortName,
|
|
"avatar_url": meta.FaviconURL,
|
|
"embeds": []any{embed},
|
|
}
|
|
return json.Marshal(payload)
|
|
}
|
|
|
|
// formatSlackPayload wraps an ATCR webhook payload in Slack's message format
|
|
func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
|
appviewURL := meta.BaseURL
|
|
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
|
|
|
|
v := p.Scan.Vulnerabilities
|
|
fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low",
|
|
title, v.Critical, v.High, v.Medium, v.Low)
|
|
|
|
description := formatVulnDescription(v, p.Manifest.Digest)
|
|
|
|
// Add previous counts for scan:changed
|
|
if p.Trigger == "scan:changed" && p.Previous != nil {
|
|
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
|
|
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
|
|
}
|
|
|
|
attachment := map[string]any{
|
|
"fallback": fallback,
|
|
"color": webhookSeverityHex(v),
|
|
"title": title,
|
|
"text": description,
|
|
"footer": meta.ClientShortName,
|
|
"footer_icon": meta.FaviconURL,
|
|
"ts": p.Scan.ScannedAt,
|
|
}
|
|
|
|
// Add repo link when handle is available
|
|
if p.Manifest.UserHandle != "" {
|
|
attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
|
attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
|
attachment["author_name"] = p.Manifest.UserHandle
|
|
attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"text": fallback,
|
|
"attachments": []any{attachment},
|
|
}
|
|
return json.Marshal(payload)
|
|
}
|