Files
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

244 lines
8.0 KiB
Go

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
}