mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
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
244 lines
8.0 KiB
Go
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
|
|
}
|