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 payload variant by probing for distinguishing keys. var probe struct { PushData json.RawMessage `json:"push_data"` QuotaData json.RawMessage `json:"quota_data"` } if err := json.Unmarshal(payload, &probe); err != nil { return nil, err } if probe.QuotaData != nil { var p QuotaWebhookPayload if err := json.Unmarshal(payload, &p); err != nil { return nil, err } if isDiscordWebhook(webhookURL) { return formatDiscordQuotaPayload(p, meta) } return formatSlackQuotaPayload(p, meta) } 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) } // formatQuotaBytes renders bytes as a human-readable size matching the appview UI. func formatQuotaBytes(b int64) string { const ( kib = 1024.0 mib = 1024.0 * kib gib = 1024.0 * mib ) f := float64(b) switch { case f >= gib: return fmt.Sprintf("%.2f GiB", f/gib) case f >= mib: return fmt.Sprintf("%.2f MiB", f/mib) case f >= kib: return fmt.Sprintf("%.2f KiB", f/kib) default: return fmt.Sprintf("%d B", b) } } // quotaColor scales severity with how close usage is to the limit. func quotaColor(usagePct int) int { switch { case usagePct >= 100: return 0xED4245 // red case usagePct >= 90: return 0xFFA500 // orange case usagePct >= 75: return 0xFEE75C // yellow default: return 0x5865F2 // blurple } } func quotaColorHex(usagePct int) string { return fmt.Sprintf("#%06X", quotaColor(usagePct)) } func quotaTitle(p QuotaWebhookPayload) string { who := p.User.Handle if who == "" { who = p.User.DID } return fmt.Sprintf("%s reached %d%% of storage quota", who, p.QuotaData.UsagePercent) } func quotaDescription(p QuotaWebhookPayload) string { return fmt.Sprintf( "%s / %s used (threshold %d%%)", formatQuotaBytes(p.QuotaData.UsageBytes), formatQuotaBytes(p.QuotaData.LimitBytes), p.QuotaData.ThresholdPercent, ) } func formatDiscordQuotaPayload(p QuotaWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { embed := map[string]any{ "title": quotaTitle(p), "description": quotaDescription(p), "color": quotaColor(p.QuotaData.UsagePercent), "footer": map[string]string{ "text": meta.ClientShortName, "icon_url": meta.FaviconURL, }, "timestamp": p.QuotaData.Timestamp, } if p.User.Handle != "" { embed["author"] = map[string]string{ "name": p.User.Handle, "url": meta.BaseURL + "/u/" + p.User.Handle, } } payload := map[string]any{ "username": meta.ClientShortName, "avatar_url": meta.FaviconURL, "embeds": []any{embed}, } return json.Marshal(payload) } func formatSlackQuotaPayload(p QuotaWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { title := quotaTitle(p) attachment := map[string]any{ "fallback": title, "color": quotaColorHex(p.QuotaData.UsagePercent), "title": title, "text": quotaDescription(p), "footer": meta.ClientShortName, "footer_icon": meta.FaviconURL, "ts": p.QuotaData.Timestamp, } if p.User.Handle != "" { attachment["author_name"] = p.User.Handle attachment["author_link"] = meta.BaseURL + "/u/" + p.User.Handle } payload := map[string]any{ "text": title, "attachments": []any{attachment}, } return json.Marshal(payload) } // 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) }