A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at label-service 296 lines 8.7 kB view raw
1package webhooks 2 3import ( 4 "encoding/json" 5 "fmt" 6 "net/url" 7 "strings" 8 9 "atcr.io/pkg/atproto" 10) 11 12// maskURL masks a URL for display (shows scheme + host, hides path/query) 13func maskURL(rawURL string) string { 14 u, err := url.Parse(rawURL) 15 if err != nil { 16 if len(rawURL) > 30 { 17 return rawURL[:30] + "***" 18 } 19 return rawURL 20 } 21 masked := u.Scheme + "://" + u.Host 22 if u.Path != "" && u.Path != "/" { 23 masked += "/***" 24 } 25 return masked 26} 27 28// isDiscordWebhook checks if the URL points to a Discord webhook endpoint 29func isDiscordWebhook(rawURL string) bool { 30 u, err := url.Parse(rawURL) 31 if err != nil { 32 return false 33 } 34 return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com") 35} 36 37// isSlackWebhook checks if the URL points to a Slack webhook endpoint 38func isSlackWebhook(rawURL string) bool { 39 u, err := url.Parse(rawURL) 40 if err != nil { 41 return false 42 } 43 return u.Host == "hooks.slack.com" 44} 45 46// webhookSeverityColor returns a color int based on the highest severity present 47func webhookSeverityColor(vulns WebhookVulnCounts) int { 48 switch { 49 case vulns.Critical > 0: 50 return 0xED4245 // red 51 case vulns.High > 0: 52 return 0xFFA500 // orange 53 case vulns.Medium > 0: 54 return 0xFEE75C // yellow 55 case vulns.Low > 0: 56 return 0x57F287 // green 57 default: 58 return 0x95A5A6 // grey 59 } 60} 61 62// webhookSeverityHex returns a hex color string (e.g., "#ED4245") 63func webhookSeverityHex(vulns WebhookVulnCounts) string { 64 return fmt.Sprintf("#%06X", webhookSeverityColor(vulns)) 65} 66 67// formatVulnDescription builds a vulnerability summary with colored square emojis 68func formatVulnDescription(v WebhookVulnCounts, digest string) string { 69 var lines []string 70 71 if len(digest) > 19 { 72 lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"...")) 73 } 74 75 if v.Total == 0 { 76 lines = append(lines, "🟩 No vulnerabilities found") 77 } else { 78 if v.Critical > 0 { 79 lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical)) 80 } 81 if v.High > 0 { 82 lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High)) 83 } 84 if v.Medium > 0 { 85 lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium)) 86 } 87 if v.Low > 0 { 88 lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low)) 89 } 90 } 91 92 return strings.Join(lines, "\n") 93} 94 95// formatPlatformPayload detects the payload type and formats for Discord or Slack 96func formatPlatformPayload(payload []byte, webhookURL string, meta atproto.AppviewMetadata) ([]byte, error) { 97 // Detect push vs scan payload by checking for push_data key 98 var probe struct { 99 PushData json.RawMessage `json:"push_data"` 100 } 101 if err := json.Unmarshal(payload, &probe); err != nil { 102 return nil, err 103 } 104 105 if probe.PushData != nil { 106 var p PushWebhookPayload 107 if err := json.Unmarshal(payload, &p); err != nil { 108 return nil, err 109 } 110 if isDiscordWebhook(webhookURL) { 111 return formatDiscordPushPayload(p, meta) 112 } 113 return formatSlackPushPayload(p, meta) 114 } 115 116 var p WebhookPayload 117 if err := json.Unmarshal(payload, &p); err != nil { 118 return nil, err 119 } 120 if isDiscordWebhook(webhookURL) { 121 return formatDiscordPayload(p, meta) 122 } 123 return formatSlackPayload(p, meta) 124} 125 126// formatDiscordPushPayload wraps a push webhook payload in Discord's embed format 127func formatDiscordPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 128 appviewURL := meta.BaseURL 129 130 title := p.Repository.Name 131 if p.PushData.Tag != "" { 132 title += ":" + p.PushData.Tag 133 } 134 135 digest := p.PushData.Digest 136 if len(digest) > 19 { 137 digest = digest[:19] + "..." 138 } 139 description := fmt.Sprintf("Pushed by **%s**\nDigest: `%s`", p.PushData.Pusher, digest) 140 141 embed := map[string]any{ 142 "title": title, 143 "url": p.Repository.RepoURL, 144 "description": description, 145 "color": 0x5865F2, // blurple 146 "footer": map[string]string{ 147 "text": meta.ClientShortName, 148 "icon_url": meta.FaviconURL, 149 }, 150 "timestamp": p.PushData.PushedAt, 151 } 152 153 embed["author"] = map[string]string{ 154 "name": p.PushData.Pusher, 155 "url": appviewURL + "/u/" + p.PushData.Pusher, 156 } 157 embed["image"] = map[string]string{ 158 "url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name), 159 } 160 161 payload := map[string]any{ 162 "username": meta.ClientShortName, 163 "avatar_url": meta.FaviconURL, 164 "embeds": []any{embed}, 165 } 166 return json.Marshal(payload) 167} 168 169// formatSlackPushPayload wraps a push webhook payload in Slack's message format 170func formatSlackPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 171 appviewURL := meta.BaseURL 172 173 title := p.Repository.Name 174 if p.PushData.Tag != "" { 175 title += ":" + p.PushData.Tag 176 } 177 178 fallback := fmt.Sprintf("%s pushed %s", p.PushData.Pusher, title) 179 180 digest := p.PushData.Digest 181 if len(digest) > 19 { 182 digest = digest[:19] + "..." 183 } 184 description := fmt.Sprintf("Pushed by *%s*\nDigest: `%s`", p.PushData.Pusher, digest) 185 186 attachment := map[string]any{ 187 "fallback": fallback, 188 "color": "#5865F2", 189 "title": title, 190 "title_link": p.Repository.RepoURL, 191 "text": description, 192 "footer": meta.ClientShortName, 193 "footer_icon": meta.FaviconURL, 194 "ts": p.PushData.PushedAt, 195 "author_name": p.PushData.Pusher, 196 "author_link": appviewURL + "/u/" + p.PushData.Pusher, 197 "image_url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name), 198 } 199 200 payload := map[string]any{ 201 "text": fallback, 202 "attachments": []any{attachment}, 203 } 204 return json.Marshal(payload) 205} 206 207// formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format 208func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 209 appviewURL := meta.BaseURL 210 title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag) 211 212 description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest) 213 214 // Add previous counts for scan:changed 215 if p.Trigger == "scan:changed" && p.Previous != nil { 216 description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d", 217 p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low) 218 } 219 220 embed := map[string]any{ 221 "title": title, 222 "url": appviewURL, 223 "description": description, 224 "color": webhookSeverityColor(p.Scan.Vulnerabilities), 225 "footer": map[string]string{ 226 "text": meta.ClientShortName, 227 "icon_url": meta.FaviconURL, 228 }, 229 "timestamp": p.Scan.ScannedAt, 230 } 231 232 // Add author, repo link, and OG image when handle is available 233 if p.Manifest.UserHandle != "" { 234 embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 235 embed["author"] = map[string]string{ 236 "name": p.Manifest.UserHandle, 237 "url": appviewURL + "/u/" + p.Manifest.UserHandle, 238 } 239 embed["image"] = map[string]string{ 240 "url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository), 241 } 242 } else { 243 embed["image"] = map[string]string{ 244 "url": appviewURL + "/og/home", 245 } 246 } 247 248 payload := map[string]any{ 249 "username": meta.ClientShortName, 250 "avatar_url": meta.FaviconURL, 251 "embeds": []any{embed}, 252 } 253 return json.Marshal(payload) 254} 255 256// formatSlackPayload wraps an ATCR webhook payload in Slack's message format 257func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 258 appviewURL := meta.BaseURL 259 title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag) 260 261 v := p.Scan.Vulnerabilities 262 fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low", 263 title, v.Critical, v.High, v.Medium, v.Low) 264 265 description := formatVulnDescription(v, p.Manifest.Digest) 266 267 // Add previous counts for scan:changed 268 if p.Trigger == "scan:changed" && p.Previous != nil { 269 description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d", 270 p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low) 271 } 272 273 attachment := map[string]any{ 274 "fallback": fallback, 275 "color": webhookSeverityHex(v), 276 "title": title, 277 "text": description, 278 "footer": meta.ClientShortName, 279 "footer_icon": meta.FaviconURL, 280 "ts": p.Scan.ScannedAt, 281 } 282 283 // Add repo link when handle is available 284 if p.Manifest.UserHandle != "" { 285 attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 286 attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository) 287 attachment["author_name"] = p.Manifest.UserHandle 288 attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle 289 } 290 291 payload := map[string]any{ 292 "text": fallback, 293 "attachments": []any{attachment}, 294 } 295 return json.Marshal(payload) 296}