Files
2026-02-28 14:42:35 -06:00

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