A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}