package notify import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "strings" "text/template" "time" ) // webhookChannel is the generic webhook adapter: the user defines the URL, // method, request headers, and JSON template. Its existence means this feature // does not need a separate implementation for Slack / Mattermost / Discord / // self-hosted systems — all of those platforms can be covered by one configurable // template. type webhookChannel struct{} func (webhookChannel) Kind() string { return KindWebhook } // Generic webhooks have no official limit; returning 0 means no rate limiting by // default, and users set one according to the peer's capacity. func (webhookChannel) DefaultRatePerMin() int { return 0 } // Mask url and headers: the target address itself often carries a token, and the // custom headers usually hold authentication credentials; both appear in API // responses, so both must be covered. // The cost is that editing a single header requires re-entering the whole set of // headers (a masked value is interpreted as "keep the original") — this trade-off // is deliberate: better to re-enter once than to echo credentials to the browser. func (webhookChannel) SecretKeys() []string { return []string{"url", "headers"} } // The destination is url. Changing url requires re-declaring headers — otherwise // the original Authorization header would be sent verbatim to the new address, // which is exactly the main path around the masking. func (webhookChannel) DestinationKeys() []string { return []string{"url"} } // webhookDefaultTemplate is the fallback request body when no template is given: // a straightforward JSON structure that covers the vast majority of self-hosted // "receive a JSON and store it" endpoints. const webhookDefaultTemplate = `{ "title": {{json .Title}}, "batch": {{.Batch}}, "count": {{.Count}}, "items": [ {{- range $i, $it := .Items}} {{- if $i}},{{end}} { "finding_id": {{$it.FindingID}}, "name": {{json $it.Name}}, "vulnclass": {{json $it.VulnClass}}, "severity": {{json $it.Severity}}, "summary": {{json $it.Summary}}, "assets": {{json $it.Assets}}, "detail_url": {{json $it.DetailURL}} } {{- end}} ] }` // webhookTemplateData is the context exposed to user templates. type webhookTemplateData struct { Title string Batch bool Count int Items []webhookItem HomeURL string // SentAt is the delivery time of this send (RFC3339), for the receiving end // to record. SentAt string } type webhookItem struct { FindingID int64 Name string VulnClass string Severity string SeverityLabel string Summary string Assets []string DetailURL string FromStatus string ToStatus string // StatusLabel is a human-readable description of the status change, e.g. // "Pending → Fixed"; empty for non-status-change events. StatusLabel string } func (webhookChannel) Validate(cfg map[string]any) error { raw := cfgString(cfg, "url") if raw == "" { return errors.New("missing target URL") } if err := validateHTTPURL(raw); err != nil { return fmt.Errorf("invalid target URL: %w", err) } if m := strings.ToUpper(cfgString(cfg, "method")); m != "" && m != http.MethodGet && m != http.MethodPost && m != http.MethodPut && m != http.MethodPatch { return fmt.Errorf("unsupported method %s (allowed: GET/POST/PUT/PATCH)", m) } if tpl := cfgString(cfg, "body_template"); tpl != "" { if _, err := parseWebhookTemplate(tpl); err != nil { return fmt.Errorf("body template syntax error: %w", err) } } return nil } func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) { if err := c.Validate(cfg); err != nil { return 0, Permanent(err) } method := strings.ToUpper(cfgString(cfg, "method")) if method == "" { method = http.MethodPost } // GET carries no request body: stuffing content into the query is beyond what // the template can express and violates GET semantics, so GET is only suitable // for "trigger a hook on hit" style receivers. var payload any if method != http.MethodGet { body, err := renderWebhookBody(cfgString(cfg, "body_template"), m) if err != nil { return 0, Permanent(err) } // The template renders JSON in string form; convert it to json.RawMessage // and send as-is, avoiding a second round of escaping that would wrap the // user's carefully built structure inside a JSON string. if !json.Valid([]byte(body)) { return 0, Permanent(errors.New("body template did not render valid JSON")) } payload = json.RawMessage(body) } headers := cfgMap(cfg, "headers") if ct := cfgString(cfg, "content_type"); ct != "" { // Allow overriding, but apply it after headers so explicit configuration // wins. if headers == nil { headers = map[string]string{} } headers["Content-Type"] = ct } if _, err := doJSON(ctx, method, cfgString(cfg, "url"), headers, payload); err != nil { return 0, err } // Generic webhooks do not truncate the body (the receiver is the user's own // service and the size is governed by body_template), so the whole batch // counts as delivered. return len(m.Items), nil } // renderWebhookBody renders the request body with the user template (or the // default template). func renderWebhookBody(tpl string, m Message) (string, error) { if strings.TrimSpace(tpl) == "" { tpl = webhookDefaultTemplate } t, err := parseWebhookTemplate(tpl) if err != nil { return "", fmt.Errorf("body template syntax error: %w", err) } var buf bytes.Buffer if err := t.Execute(&buf, newWebhookTemplateData(m)); err != nil { return "", fmt.Errorf("failed to render body template: %w", err) } return buf.String(), nil } // parseWebhookTemplate parses the template. // // missingkey=zero renders missing map keys as zero values instead of erroring — // but the context in this file is a struct, so its main effect is keeping range // from erroring when .Items is empty. What really needs guarding is .Items being // nil. func parseWebhookTemplate(tpl string) (*template.Template, error) { return template.New("body").Funcs(webhookTemplateFuncs).Option("missingkey=zero").Parse(tpl) } // webhookTemplateFuncs are the helper functions exposed to templates. var webhookTemplateFuncs = template.FuncMap{ // json serializes any value into JSON. // // This function is not a nice-to-have but a necessity: without it users can // only write {{.Title}} for direct interpolation, and a single quote or // newline in a vulnerability title makes the whole request body invalid // JSON — the receiver rejects it, and the error points at "JSON parse // failure" with no hint that a quote in the title caused it. "json": func(v any) (string, error) { raw, err := json.Marshal(v) if err != nil { return "", err } return string(raw), nil }, // jsons embeds a JSON fragment inside another JSON string value (one level of // string escaping). "jsons": func(v any) (string, error) { raw, err := json.Marshal(v) if err != nil { return "", err } quoted, err := json.Marshal(string(raw)) if err != nil { return "", err } // Strip the outer quotes: the caller decides whether to add quotes. return string(quoted[1 : len(quoted)-1]), nil }, } func newWebhookTemplateData(m Message) webhookTemplateData { d := webhookTemplateData{ Title: markdownTitle(m), Batch: m.Batch, Count: len(m.Items), HomeURL: m.HomeURL, SentAt: time.Now().Format(time.RFC3339), Items: make([]webhookItem, 0, len(m.Items)), } for _, it := range m.Items { wi := webhookItem{ FindingID: it.FindingID, Name: it.Name, VulnClass: it.VulnClass, Severity: it.Severity, SeverityLabel: SeverityLabel(it.Severity), Summary: it.Summary, Assets: append([]string{}, it.Assets...), DetailURL: it.DetailURL, FromStatus: it.FromStatus, ToStatus: it.ToStatus, } if it.IsStatusChange() { wi.StatusLabel = StatusLabel(it.FromStatus) + " → " + StatusLabel(it.ToStatus) } d.Items = append(d.Items, wi) } return d }