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) }