mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cc22cb794 | ||
|
|
3b6eb2fd2c | ||
|
|
2ab1046746 | ||
|
|
809436e111 | ||
|
|
c84f124cb4 | ||
|
|
cf0b0c1002 |
+8
-2
@@ -633,14 +633,20 @@ LIMIT $1`, malformedIPAssetsSampled)
|
||||
if total == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return malformedIPAssetWarningText(total, samples), nil
|
||||
}
|
||||
|
||||
// malformedIPAssetWarningText words the warning for total offending assets, of
|
||||
// which only samples are named; the ones left out are counted in the suffix.
|
||||
func malformedIPAssetWarningText(total int, samples []string) string {
|
||||
warning := fmt.Sprintf(
|
||||
"%d assets have an ip field that is not a valid IP; skipped for IP/CIDR scope matching (these assets will not be attributed to a company by subnet rules): %s",
|
||||
total, strings.Join(samples, ", "),
|
||||
)
|
||||
if total > len(samples) {
|
||||
warning += fmt.Sprintf(" and %d more", total)
|
||||
warning += fmt.Sprintf(" and %d more", total-len(samples))
|
||||
}
|
||||
return warning, nil
|
||||
return warning
|
||||
}
|
||||
|
||||
// UpdateScope replaces all scope rules for a company and reattributes.
|
||||
|
||||
@@ -458,3 +458,21 @@ func TestListCompanies(t *testing.T) {
|
||||
t.Error("ListCompanies: created company not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedIPAssetWarningTextCountsOmitted(t *testing.T) {
|
||||
samples := []string{"#1 a", "#2 b", "#3 c", "#4 d", "#5 e"}
|
||||
// The suffix must count the assets left out, not repeat the total, otherwise
|
||||
// the operator is told about more bad rows than exist.
|
||||
got := malformedIPAssetWarningText(8, samples)
|
||||
if !strings.HasPrefix(got, "8 assets have an ip field") {
|
||||
t.Fatalf("the warning should lead with the total of 8, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, ": #1 a, #2 b, #3 c, #4 d, #5 e and 3 more") {
|
||||
t.Fatalf("8 assets with 5 named should note the 3 omitted, got %q", got)
|
||||
}
|
||||
// When every offending asset is named there is nothing to add.
|
||||
got = malformedIPAssetWarningText(5, samples)
|
||||
if !strings.HasSuffix(got, ": #1 a, #2 b, #3 c, #4 d, #5 e") {
|
||||
t.Fatalf("5 assets with 5 named should have no suffix, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +79,28 @@ type Message struct {
|
||||
// HomeURL is the platform dashboard address (global public_base_url); empty
|
||||
// means no dashboard entry is included.
|
||||
HomeURL string
|
||||
// Limits are the truncation limits to render with; nil means the defaults.
|
||||
// A pointer because the zero value is meaningful (no truncation).
|
||||
Limits *Limits
|
||||
}
|
||||
|
||||
// Limits are the render-time truncation limits of a message, configured
|
||||
// globally (see the server's notify_summary_max_runes / notify_assets_max_shown
|
||||
// settings). In both fields 0 means no truncation; each channel's own length
|
||||
// cap remains the final guard.
|
||||
type Limits struct {
|
||||
// SummaryRunes is how many characters the summary of a single push is
|
||||
// compressed to.
|
||||
SummaryRunes int
|
||||
// AssetsShown is the maximum number of assets listed per vulnerability.
|
||||
AssetsShown int
|
||||
}
|
||||
|
||||
// limits returns the limits to render with, falling back to the defaults when
|
||||
// none were passed in.
|
||||
func (m Message) limits() Limits {
|
||||
if m.Limits == nil {
|
||||
return Limits{SummaryRunes: DefaultMaxSummaryRunes, AssetsShown: DefaultMaxAssetsShown}
|
||||
}
|
||||
return *m.Limits
|
||||
}
|
||||
|
||||
+17
-12
@@ -121,15 +121,20 @@ func feishuSeverityTemplate(severity string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// feishuMaxCardBytes is a conservative cap on card content. Feishu enforces a
|
||||
// size limit on cards and rejects the whole message when exceeded; pick a value
|
||||
// clearly below the official limit to also cover JSON wrapping overhead.
|
||||
const feishuMaxCardBytes = 24000
|
||||
// feishuMaxCardBytes is a conservative cap on card content. Feishu custom bots
|
||||
// reject a request body over 20 KB as a whole (not truncated); pick a value
|
||||
// clearly below that limit to also cover JSON wrapping overhead.
|
||||
const feishuMaxCardBytes = 16000
|
||||
|
||||
// feishuCard builds the interactive card and returns it along with the **number
|
||||
// of items actually written**. kept serves the same purpose as in markdownBody:
|
||||
// only items that actually made it into the card should be marked as sent.
|
||||
func feishuCard(m Message) (map[string]any, int) {
|
||||
lim := m.limits()
|
||||
// budget is the final truncation backstop for one item's text, the same role
|
||||
// TruncateBytes plays in markdownBody. With the summary or asset limit set to
|
||||
// 0 an item has no other bound, and an oversized card loses the whole message.
|
||||
budget := feishuMaxCardBytes - markdownReservedBytes
|
||||
elements := []any{}
|
||||
kept := 0
|
||||
if m.Batch {
|
||||
@@ -137,12 +142,12 @@ func feishuCard(m Message) (map[string]any, int) {
|
||||
// "the remaining N items will continue in the next message", and N must
|
||||
// come from the number of items that actually fit.
|
||||
kept = packItemCount(m.Items, feishuMaxCardBytes, markdownReservedBytes, "", byteSize, func(it Item, idx int) string {
|
||||
return feishuBatchLine(it, idx+1)
|
||||
return feishuBatchLine(it, idx+1, lim)
|
||||
})
|
||||
items := m.Items[:kept]
|
||||
elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m, items, len(m.Items))))
|
||||
for i, it := range items {
|
||||
elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1)))
|
||||
elements = append(elements, feishuMarkdownDiv(TruncateBytes(feishuBatchLine(it, i+1, lim), budget)))
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
elements = append(elements, feishuButton("View all on the platform", m.HomeURL))
|
||||
@@ -150,7 +155,7 @@ func feishuCard(m Message) (map[string]any, int) {
|
||||
} else if len(m.Items) > 0 {
|
||||
kept = 1
|
||||
it := m.Items[0]
|
||||
elements = append(elements, feishuMarkdownDiv(feishuItemLines(it)))
|
||||
elements = append(elements, feishuMarkdownDiv(TruncateBytes(feishuItemLines(it, lim), budget)))
|
||||
if it.DetailURL != "" {
|
||||
elements = append(elements, feishuButton("View details", it.DetailURL))
|
||||
}
|
||||
@@ -189,7 +194,7 @@ func feishuButton(label, url string) map[string]any {
|
||||
// emphasis, so every field coming from outside goes through markdownText
|
||||
// (single-lining + escaping) — otherwise a single vulnerability title could turn
|
||||
// into a clickable external link inside Feishu.
|
||||
func feishuItemLines(it Item) string {
|
||||
func feishuItemLines(it Item, lim Limits) string {
|
||||
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if it.IsStatusChange() {
|
||||
out += fmt.Sprintf("\n**Status change**: %s → %s",
|
||||
@@ -198,11 +203,11 @@ func feishuItemLines(it Item) string {
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
out += fmt.Sprintf("\n**Type**: %s", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
out += fmt.Sprintf("\n**Assets**: %s", markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||||
if s := markdownText(it.Summary, lim.SummaryRunes); s != "" {
|
||||
out += fmt.Sprintf("\n**Summary**: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -210,9 +215,9 @@ func feishuItemLines(it Item) string {
|
||||
}
|
||||
|
||||
// feishuBatchLine renders one entry of a digest card.
|
||||
func feishuBatchLine(it Item, index int) string {
|
||||
func feishuBatchLine(it Item, index int, lim Limits) string {
|
||||
line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
line += " — " + markdownText(a, 0)
|
||||
}
|
||||
return line
|
||||
|
||||
+6
-6
@@ -39,10 +39,10 @@ func htmlBody(m Message, maxRunes int) string {
|
||||
if m.Batch {
|
||||
b.WriteString(htmlBatchIntro(m))
|
||||
for _, it := range m.Items {
|
||||
b.WriteString(htmlItem(it, false))
|
||||
b.WriteString(htmlItem(it, false, m.limits()))
|
||||
}
|
||||
} else if len(m.Items) > 0 {
|
||||
b.WriteString(htmlItem(m.Items[0], true))
|
||||
b.WriteString(htmlItem(m.Items[0], true, m.limits()))
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
fmt.Fprintf(&b, `<p style="margin:16px 0 0;"><a href="%s" style="color:#1677ff;">View all on the platform</a></p>`, htmlEscapeAttr(m.HomeURL))
|
||||
@@ -79,7 +79,7 @@ func htmlBatchIntro(m Message) string {
|
||||
|
||||
// htmlItem renders a single vulnerability. full=true includes the summary and
|
||||
// backlink (single push); false compresses it into one line (digest list).
|
||||
func htmlItem(it Item, full bool) string {
|
||||
func htmlItem(it Item, full bool, lim Limits) string {
|
||||
color := htmlSeverityColor(it.Severity)
|
||||
var b strings.Builder
|
||||
if full {
|
||||
@@ -92,7 +92,7 @@ func htmlItem(it Item, full bool) string {
|
||||
|
||||
if !full {
|
||||
var extras []string
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
extras = append(extras, htmlEscape(a))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
@@ -112,10 +112,10 @@ func htmlItem(it Item, full bool) string {
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
fmt.Fprintf(&b, `<div><b>Type</b>: %s</div>`, htmlEscape(it.VulnClass))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
fmt.Fprintf(&b, `<div><b>Assets</b>: %s</div>`, htmlEscape(a))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
if s := OneLine(it.Summary, lim.SummaryRunes); s != "" {
|
||||
fmt.Fprintf(&b, `<div><b>Summary</b>: %s</div>`, htmlEscape(s))
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// This file covers the configurable render limits (Message.Limits). The summary
|
||||
// length and the number of assets listed used to be the fixed constants 120 / 3;
|
||||
// they now come from the caller, with 0 meaning "no truncation" and nil meaning
|
||||
// "the old defaults".
|
||||
|
||||
// limitsItem returns an item whose summary (200 characters) and asset list (8)
|
||||
// both exceed the defaults. It contains no markdown/HTML metacharacters, so
|
||||
// every renderer emits the text verbatim and one set of assertions fits all.
|
||||
func limitsItem() Item {
|
||||
assets := make([]string, 8)
|
||||
for i := range assets {
|
||||
assets[i] = "host" + itoa(i+1)
|
||||
}
|
||||
return Item{Severity: "high", Name: "title", Summary: strings.Repeat("Z", 200), Assets: assets}
|
||||
}
|
||||
|
||||
// feishuContent returns the lark_md text of the card's element idx.
|
||||
func feishuContent(t *testing.T, m Message, idx int) string {
|
||||
t.Helper()
|
||||
card, _ := feishuCard(m)
|
||||
elements, _ := card["elements"].([]any)
|
||||
if idx >= len(elements) {
|
||||
t.Fatalf("card has %d elements, cannot get element %d", len(elements), idx)
|
||||
}
|
||||
div, _ := elements[idx].(map[string]any)
|
||||
text, _ := div["text"].(map[string]any)
|
||||
content, _ := text["content"].(string)
|
||||
return content
|
||||
}
|
||||
|
||||
// limitsRenderings returns the body of every channel family that applies the
|
||||
// limits. For a digest, the Feishu entry is the first item line (element 0 is
|
||||
// the intro).
|
||||
func limitsRenderings(t *testing.T, m Message) map[string]string {
|
||||
t.Helper()
|
||||
md, _ := markdownBody(m, 0)
|
||||
tg, _ := telegramHTML(m)
|
||||
feishuIdx := 0
|
||||
if m.Batch {
|
||||
feishuIdx = 1
|
||||
}
|
||||
return map[string]string{
|
||||
"markdown": md,
|
||||
"feishu": feishuContent(t, m, feishuIdx),
|
||||
"telegram": tg,
|
||||
"email": htmlBody(m, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageLimitsApplyToSinglePush(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
lim *Limits
|
||||
must []string
|
||||
wrong []string
|
||||
}{
|
||||
{
|
||||
name: "unset keeps the 120 / 3 defaults",
|
||||
lim: nil,
|
||||
must: []string{strings.Repeat("Z", 119) + ellipsis, "host1, host2, host3 and 5 more"},
|
||||
wrong: []string{strings.Repeat("Z", 120), "host4"},
|
||||
},
|
||||
{
|
||||
name: "zero means no truncation",
|
||||
lim: &Limits{},
|
||||
must: []string{strings.Repeat("Z", 200), "host7, host8"},
|
||||
wrong: []string{ellipsis, " more"},
|
||||
},
|
||||
{
|
||||
name: "custom values",
|
||||
lim: &Limits{SummaryRunes: 10, AssetsShown: 1},
|
||||
must: []string{strings.Repeat("Z", 9) + ellipsis, "host1 and 7 more"},
|
||||
wrong: []string{strings.Repeat("Z", 10), "host2"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := Message{Items: []Item{limitsItem()}, Limits: tc.lim}
|
||||
for channel, got := range limitsRenderings(t, m) {
|
||||
for _, want := range tc.must {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("%s: missing %q:\n%s", channel, want, got)
|
||||
}
|
||||
}
|
||||
for _, bad := range tc.wrong {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("%s: %q should not appear:\n%s", channel, bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageLimitsApplyToDigestAssets covers the digest lines, which list the
|
||||
// assets too. The digest summary is not governed by Limits.SummaryRunes: it
|
||||
// stays at its own fixed 60 characters (markdown and email), a digest being a
|
||||
// list of one-liners.
|
||||
func TestMessageLimitsApplyToDigestAssets(t *testing.T) {
|
||||
m := Message{Batch: true, Items: []Item{limitsItem()}}
|
||||
for channel, got := range limitsRenderings(t, m) {
|
||||
if !strings.Contains(got, "host1, host2, host3 and 5 more") {
|
||||
t.Errorf("%s: unset should keep 3 assets:\n%s", channel, got)
|
||||
}
|
||||
}
|
||||
m.Limits = &Limits{}
|
||||
for channel, got := range limitsRenderings(t, m) {
|
||||
if !strings.Contains(got, "host7, host8") || strings.Contains(got, " more") {
|
||||
t.Errorf("%s: 0 should list every asset:\n%s", channel, got)
|
||||
}
|
||||
if strings.Contains(got, strings.Repeat("Z", 60)) {
|
||||
t.Errorf("%s: the digest summary should stay within 60 characters:\n%s", channel, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFeishuUntruncatedItemStaysWithinBodyLimit is the guard that goes with
|
||||
// "0 = no truncation". Feishu custom bots reject a request body over 20 KB as a
|
||||
// whole, so an unbounded summary or asset list would not arrive shortened — it
|
||||
// would not arrive at all. The body measured here is the real one sent on the
|
||||
// wire, signature included.
|
||||
func TestFeishuUntruncatedItemStaysWithinBodyLimit(t *testing.T) {
|
||||
const feishuBodyLimit = 20000
|
||||
|
||||
huge := limitsItem()
|
||||
huge.DetailURL = "https://artex.local/function/findings/detail?id=42"
|
||||
// 36000 bytes of CJK: also checks that the cut lands on a character boundary.
|
||||
huge.Summary = strings.Repeat("漏洞摘要", 3000)
|
||||
huge.Assets = nil
|
||||
for i := 0; i < 1500; i++ {
|
||||
huge.Assets = append(huge.Assets, "host"+itoa(i+1)+".example.com")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
m Message
|
||||
idx int
|
||||
}{
|
||||
{"single push", Message{Items: []Item{huge}, Limits: &Limits{}}, 0},
|
||||
{"digest whose first item is oversized", Message{
|
||||
Batch: true, WindowMinutes: 30, HomeURL: "https://artex.local/function/findings",
|
||||
Items: []Item{huge, huge}, Limits: &Limits{},
|
||||
}, 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
size := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
size = len(raw)
|
||||
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kept, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL, "secret": "SECtest123"}, tc.m)
|
||||
if err != nil {
|
||||
t.Fatalf("delivery failed: %v", err)
|
||||
}
|
||||
if kept != 1 {
|
||||
t.Fatalf("exactly the first item should be reported as delivered, got %d", kept)
|
||||
}
|
||||
if size == 0 || size > feishuBodyLimit {
|
||||
t.Fatalf("request body of %d bytes, want 1..%d", size, feishuBodyLimit)
|
||||
}
|
||||
content := feishuContent(t, tc.m, tc.idx)
|
||||
if !utf8.ValidString(content) || !strings.HasSuffix(content, ellipsis) {
|
||||
t.Fatalf("the item should be cut on a character boundary and end with an ellipsis, got tail %q", content[len(content)-12:])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -9,16 +9,16 @@ import (
|
||||
// (DingTalk, WeCom). Feishu uses card JSON, Telegram uses HTML, and email uses
|
||||
// HTML; each renders in its own adapter.
|
||||
|
||||
// maxAssetsShown is the maximum number of assets listed in a message. A
|
||||
// vulnerability can be anchored to dozens of assets; listing them all would blow
|
||||
// up the message with no informational value — nobody reads the 4th domain
|
||||
// onwards in an IM.
|
||||
const maxAssetsShown = 3
|
||||
// DefaultMaxAssetsShown is the default maximum number of assets listed in a
|
||||
// message (Limits.AssetsShown). A vulnerability can be anchored to dozens of
|
||||
// assets; listing them all would blow up the message with no informational
|
||||
// value — nobody reads the 4th domain onwards in an IM.
|
||||
const DefaultMaxAssetsShown = 3
|
||||
|
||||
// maxSummaryRunes is how many characters the summary is compressed to. An IM
|
||||
// message is a "go look at the details" hint, not the report itself; the full
|
||||
// content lives on the platform.
|
||||
const maxSummaryRunes = 120
|
||||
// DefaultMaxSummaryRunes is the default number of characters the summary is
|
||||
// compressed to (Limits.SummaryRunes). An IM message is a "go look at the
|
||||
// details" hint, not the report itself; the full content lives on the platform.
|
||||
const DefaultMaxSummaryRunes = 120
|
||||
|
||||
// markdownReservedBytes is reserved for the message header (digest line +
|
||||
// severity distribution + possible truncation notice) and footer (platform
|
||||
@@ -114,7 +114,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
return "", 0
|
||||
}
|
||||
var b strings.Builder
|
||||
writeItem(&b, m.Items[0], "", true)
|
||||
writeItem(&b, m.Items[0], "", true, m.limits())
|
||||
// A single message is sent even if oversized (with final truncation as
|
||||
// the backstop): partial information about one vulnerability is better
|
||||
// than sending nothing at all.
|
||||
@@ -127,7 +127,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
}
|
||||
kept := packItemCount(m.Items, maxBytes, markdownReservedBytes, footer, byteSize, func(it Item, idx int) string {
|
||||
var b strings.Builder
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false)
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false, m.limits())
|
||||
return b.String()
|
||||
})
|
||||
|
||||
@@ -135,7 +135,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
var b strings.Builder
|
||||
b.WriteString(markdownBatchIntro(m, items, len(m.Items)))
|
||||
for i, it := range items {
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false, m.limits())
|
||||
}
|
||||
b.WriteString(footer)
|
||||
return TruncateBytes(b.String(), maxBytes), kept
|
||||
@@ -191,13 +191,13 @@ func markdownBatchIntro(m Message, items []Item, total int) string {
|
||||
// markdownText: single-lining + escaping. The backlink is built from the
|
||||
// admin-configured public_base_url, is not untrusted content, and must be a
|
||||
// clickable link, so it is emitted as-is.
|
||||
func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||||
func writeItem(b *strings.Builder, it Item, prefix string, single bool, lim Limits) {
|
||||
line := fmt.Sprintf("%s**%s · %s**", prefix, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if !single {
|
||||
// Digest mode: single-line presentation, with assets and summary
|
||||
// compressed and appended.
|
||||
var extras []string
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
extras = append(extras, markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
@@ -217,11 +217,11 @@ func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
fmt.Fprintf(b, "**Type**: %s\n", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
fmt.Fprintf(b, "**Assets**: %s\n", markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||||
if s := markdownText(it.Summary, lim.SummaryRunes); s != "" {
|
||||
fmt.Fprintf(b, "**Summary**: %s\n", s)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ func TestMarkdownEscapesUntrustedContent(t *testing.T) {
|
||||
// writeItem in single mode is the rendering path shared by all three
|
||||
// markdown channels.
|
||||
var b strings.Builder
|
||||
writeItem(&b, tc.item, "", true)
|
||||
writeItem(&b, tc.item, "", true, m.limits())
|
||||
got := b.String()
|
||||
for _, want := range tc.must {
|
||||
if !strings.Contains(got, want) {
|
||||
|
||||
+7
-6
@@ -115,6 +115,7 @@ func telegramEndpoint(cfg map[string]any) (string, error) {
|
||||
// telegramHTML renders the HTML body and returns it together with the number of
|
||||
// items actually written (see Channel.Send).
|
||||
func telegramHTML(m Message) (string, int) {
|
||||
lim := m.limits()
|
||||
var b strings.Builder
|
||||
b.WriteString("<b>" + telegramEscape(markdownTitle(m)) + "</b>\n")
|
||||
if m.Batch {
|
||||
@@ -125,13 +126,13 @@ func telegramHTML(m Message) (string, int) {
|
||||
footer = fmt.Sprintf("\n\n<a href=\"%s\">View all on the platform</a>", telegramEscapeAttr(m.HomeURL))
|
||||
}
|
||||
kept := packItemCount(m.Items, telegramTextLimit, telegramReservedRunes, footer, runeSize, func(it Item, idx int) string {
|
||||
return telegramBatchLine(it, idx+1)
|
||||
return telegramBatchLine(it, idx+1, lim)
|
||||
})
|
||||
items := m.Items[:kept]
|
||||
b.Reset()
|
||||
b.WriteString("<b>" + telegramEscape(telegramBatchTitle(m, items, len(m.Items))) + "</b>")
|
||||
for i, it := range items {
|
||||
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1)))
|
||||
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1, lim)))
|
||||
}
|
||||
b.WriteString(footer)
|
||||
return TruncateHTML(b.String(), telegramTextLimit), kept
|
||||
@@ -147,10 +148,10 @@ func telegramHTML(m Message) (string, int) {
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
b.WriteString("\n<b>Type</b>: " + telegramEscape(it.VulnClass))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
b.WriteString("\n<b>Assets</b>: " + telegramEscape(a))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
if s := OneLine(it.Summary, lim.SummaryRunes); s != "" {
|
||||
b.WriteString("\n<b>Summary</b>: " + telegramEscape(s))
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
@@ -165,8 +166,8 @@ const telegramReservedRunes = 160
|
||||
|
||||
// telegramBatchLine renders one entry of a digest (unescaped; the caller escapes
|
||||
// it uniformly).
|
||||
func telegramBatchLine(it Item, idx int) string {
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
func telegramBatchLine(it Item, idx int, lim Limits) string {
|
||||
if a := assetLine(it.Assets, lim.AssetsShown); a != "" {
|
||||
return fmt.Sprintf("%d. %s · %s — %s", idx, SeverityLabel(it.Severity), it.Title(), a)
|
||||
}
|
||||
return fmt.Sprintf("%d. %s · %s", idx, SeverityLabel(it.Severity), it.Title())
|
||||
|
||||
+43
-5
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -25,6 +27,12 @@ const (
|
||||
settingNotifyPublicBaseURL = "notify_public_base_url"
|
||||
// settingNotifyDigestMinutes is the digest-mode period (minutes).
|
||||
settingNotifyDigestMinutes = "notify_digest_interval_min"
|
||||
// settingNotifySummaryMaxRunes is how many characters the summary of a single
|
||||
// push is cut to, and settingNotifyAssetsMaxShown how many asset names a
|
||||
// message lists. 0 means no truncation; unset means the notify package
|
||||
// defaults (120 / 3).
|
||||
settingNotifySummaryMaxRunes = "notify_summary_max_runes"
|
||||
settingNotifyAssetsMaxShown = "notify_assets_max_shown"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -43,6 +51,10 @@ const (
|
||||
notifyFanOutPerTick = 200
|
||||
// notifyDefaultDigestMinutes is the default digest period.
|
||||
notifyDefaultDigestMinutes = 30
|
||||
// notifySummaryMaxRunesCap / notifyAssetsMaxShownCap are the upper bounds the
|
||||
// settings API accepts for the two render limits.
|
||||
notifySummaryMaxRunesCap = 4000
|
||||
notifyAssetsMaxShownCap = 50
|
||||
// notifyUnlimitedBurstPerTick is the per-tick delivery cap when a channel has
|
||||
// no rate limit configured. It exists to prevent "one channel configured as
|
||||
// unlimited + a single scan finding thousands of vulnerabilities" from dragging
|
||||
@@ -223,8 +235,9 @@ func (n *Notifier) stepRealtime(ctx context.Context, ch *db.NotificationChannel,
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("channel type %q is not registered", ch.Kind))
|
||||
return
|
||||
}
|
||||
limits := notifyLimits(n.pg)
|
||||
for _, dl := range deliveries {
|
||||
msg, err := n.renderSingle(ctx, dl, baseURL)
|
||||
msg, err := n.renderSingle(ctx, dl, baseURL, limits)
|
||||
if err != nil {
|
||||
// A render failure is a local data problem; retrying will not help.
|
||||
_ = n.pg.FailDeliveries(ctx, []int64{dl.ID}, err.Error())
|
||||
@@ -259,7 +272,7 @@ func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, a
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("channel type %q is not registered", ch.Kind))
|
||||
return
|
||||
}
|
||||
msg, included, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()))
|
||||
msg, included, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()), notifyLimits(n.pg))
|
||||
if err != nil {
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), err.Error())
|
||||
return
|
||||
@@ -423,7 +436,7 @@ func (n *Notifier) adapt(ch *db.NotificationChannel) (notify.Channel, map[string
|
||||
}
|
||||
|
||||
// renderSingle renders a single-vulnerability message.
|
||||
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string) (notify.Message, error) {
|
||||
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string, limits notify.Limits) (notify.Message, error) {
|
||||
snap, err := parseSnapshot(dl)
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
@@ -432,7 +445,7 @@ func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
}
|
||||
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL}, nil
|
||||
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL, Limits: &limits}, nil
|
||||
}
|
||||
|
||||
// renderBatch renders a digest message. Snapshots are parsed one by one — a
|
||||
@@ -446,7 +459,7 @@ func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery
|
||||
// entry that should have failed would be marked sent, and a good entry would be
|
||||
// misjudged as undelivered. The broken ones are marked failed explicitly by the
|
||||
// caller, see stepDigest.
|
||||
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int) (notify.Message, []*db.NotificationDelivery, error) {
|
||||
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int, limits notify.Limits) (notify.Message, []*db.NotificationDelivery, error) {
|
||||
items := make([]notify.Item, 0, len(deliveries))
|
||||
included := make([]*db.NotificationDelivery, 0, len(deliveries))
|
||||
for _, dl := range deliveries {
|
||||
@@ -473,6 +486,7 @@ func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.Notificatio
|
||||
Batch: true,
|
||||
WindowMinutes: windowMinutes,
|
||||
HomeURL: baseURL,
|
||||
Limits: &limits,
|
||||
}, included, nil
|
||||
}
|
||||
|
||||
@@ -582,6 +596,30 @@ func (n *Notifier) digestInterval() time.Duration {
|
||||
return time.Duration(m) * time.Minute
|
||||
}
|
||||
|
||||
// notifyLimits reads the summary/asset render limits from the settings.
|
||||
func notifyLimits(pg *db.DB) notify.Limits {
|
||||
summary, sOK, _ := pg.GetSetting(settingNotifySummaryMaxRunes)
|
||||
assets, aOK, _ := pg.GetSetting(settingNotifyAssetsMaxShown)
|
||||
return notify.Limits{
|
||||
SummaryRunes: notifyLimitValue(summary, sOK, notify.DefaultMaxSummaryRunes, notifySummaryMaxRunesCap),
|
||||
AssetsShown: notifyLimitValue(assets, aOK, notify.DefaultMaxAssetsShown, notifyAssetsMaxShownCap),
|
||||
}
|
||||
}
|
||||
|
||||
// notifyLimitValue resolves one stored render limit: def when the key is unset,
|
||||
// unparseable, or outside [0, max]. 0 is a valid value (no truncation), which is
|
||||
// why "unset" is carried by ok and not by the number.
|
||||
func notifyLimitValue(v string, ok bool, def, max int) int {
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n < 0 || n > max {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseSnapshot parses the snapshot of the event a delivery corresponds to.
|
||||
func parseSnapshot(dl *db.NotificationDelivery) (notify.Snapshot, error) {
|
||||
var snap notify.Snapshot
|
||||
|
||||
@@ -447,6 +447,8 @@ func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
msg := notifyTestMessage(s.notifierBaseURL(pg))
|
||||
limits := notifyLimits(pg)
|
||||
msg.Limits = &limits
|
||||
start := time.Now()
|
||||
// The test message is a single entry, so the delivered count is not needed
|
||||
// here (for a single message the channel length limit falls back to
|
||||
|
||||
@@ -700,6 +700,108 @@ func TestNotifyMetaAndSettingsRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyLimitSettingsRoundTrip covers the two render-limit keys through the
|
||||
// settings API and the settings table: unset reads as the 120 / 3 defaults, a
|
||||
// stored 0 reads back as 0 (no truncation) and not as the default, and
|
||||
// out-of-range values are rejected without touching the stored value.
|
||||
func TestNotifyLimitSettingsRoundTrip(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
clear := func() {
|
||||
f.pg.Exec(`DELETE FROM settings WHERE key IN ($1,$2)`, settingNotifySummaryMaxRunes, settingNotifyAssetsMaxShown)
|
||||
}
|
||||
clear()
|
||||
t.Cleanup(clear)
|
||||
expect := func(step string, summary, assets int) {
|
||||
t.Helper()
|
||||
payload := f.s.settingsPayload()
|
||||
if payload["notify_summary_max_runes"] != summary || payload["notify_assets_max_shown"] != assets {
|
||||
t.Fatalf("%s: settings payload should report %d / %d, got %v / %v", step, summary, assets,
|
||||
payload["notify_summary_max_runes"], payload["notify_assets_max_shown"])
|
||||
}
|
||||
if got := notifyLimits(f.pg); got != (notify.Limits{SummaryRunes: summary, AssetsShown: assets}) {
|
||||
t.Fatalf("%s: the delivery engine should read %d / %d, got %+v", step, summary, assets, got)
|
||||
}
|
||||
}
|
||||
expect("unset", notify.DefaultMaxSummaryRunes, notify.DefaultMaxAssetsShown)
|
||||
|
||||
for _, tc := range []struct{ summary, assets int }{{500, 10}, {0, 0}, {4000, 50}} {
|
||||
body := fmt.Sprintf(`{"notify_summary_max_runes":%d,"notify_assets_max_shown":%d}`, tc.summary, tc.assets)
|
||||
if r := f.request("PUT", "/api/settings", body); r.Code != 200 {
|
||||
t.Fatalf("%s failed %d: %s", body, r.Code, r.Body)
|
||||
}
|
||||
expect(body, tc.summary, tc.assets)
|
||||
for key, want := range map[string]int{settingNotifySummaryMaxRunes: tc.summary, settingNotifyAssetsMaxShown: tc.assets} {
|
||||
if v, ok, err := f.pg.GetSetting(key); err != nil || !ok || v != strconv.Itoa(want) {
|
||||
t.Fatalf("%s: settings row %s should hold %d, got %q (ok=%v err=%v)", body, key, want, v, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, body := range []string{
|
||||
`{"notify_summary_max_runes":-1}`,
|
||||
`{"notify_summary_max_runes":4001}`,
|
||||
`{"notify_assets_max_shown":-1}`,
|
||||
`{"notify_assets_max_shown":51}`,
|
||||
} {
|
||||
if r := f.request("PUT", "/api/settings", body); r.Code != 400 {
|
||||
t.Errorf("%s should return 400, got %d", body, r.Code)
|
||||
}
|
||||
}
|
||||
expect("after rejected writes", 4000, 50)
|
||||
}
|
||||
|
||||
// TestNotifySummaryLimitReachesDelivery is the end-to-end evidence that the
|
||||
// stored limit reaches the message actually sent: with the key unset the summary
|
||||
// is cut at 120 characters as before, and once it is set to 0 the same summary
|
||||
// goes out whole.
|
||||
func TestNotifySummaryLimitReachesDelivery(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
clear := func() { f.pg.Exec(`DELETE FROM settings WHERE key=$1`, settingNotifySummaryMaxRunes) }
|
||||
clear()
|
||||
t.Cleanup(clear)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "summary limit",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
summary := strings.Repeat("Z", 300)
|
||||
record := func(name string) {
|
||||
t.Helper()
|
||||
if _, err := f.s.evidenceStore().Record(context.Background(), db.RecordFindingInput{
|
||||
TaskID: f.taskID,
|
||||
ExplorationID: f.expID,
|
||||
Worker: "test",
|
||||
VulnClass: name,
|
||||
Name: name,
|
||||
Severity: "high",
|
||||
Summary: summary,
|
||||
Evidence: "poc",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("failed to record finding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
record("summary limit unset")
|
||||
f.deliver(t, chID, "")
|
||||
text := markdownText(t, hook.last(t))
|
||||
if !strings.Contains(text, strings.Repeat("Z", 119)+"…") || strings.Contains(text, strings.Repeat("Z", 120)) {
|
||||
t.Fatalf("with the key unset the summary should be cut at 120 characters:\n%s", text)
|
||||
}
|
||||
|
||||
if r := f.request("PUT", "/api/settings", `{"notify_summary_max_runes":0}`); r.Code != 200 {
|
||||
t.Fatalf("failed to write settings %d: %s", r.Code, r.Body)
|
||||
}
|
||||
record("summary limit zero")
|
||||
f.deliver(t, chID, "")
|
||||
if hook.count() != 2 {
|
||||
t.Fatalf("expected 2 messages sent, got %d", hook.count())
|
||||
}
|
||||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, summary) {
|
||||
t.Fatalf("with the limit at 0 the whole summary should be sent:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyDeepLinkUsesPublicBaseURL covers back-link building: when
|
||||
// public_base_url is configured, single messages must use the ActionCard with a
|
||||
// button, and the link must point to the vulnerability detail page.
|
||||
@@ -1008,3 +1110,30 @@ func TestNotifyTickBudgetFitsWithinLease(t *testing.T) {
|
||||
worst, notifyLease, notifyMaxSendsPerChannelPerTick, notifySendTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyLimitValue pins how a stored render limit is resolved. The case that
|
||||
// matters is "0": it is a real value (no truncation) and must not be read as
|
||||
// "unset", while anything unparseable or outside the bounds falls back to the
|
||||
// default instead of reaching the renderer.
|
||||
func TestNotifyLimitValue(t *testing.T) {
|
||||
const def, max = 120, 4000
|
||||
cases := []struct {
|
||||
v string
|
||||
ok bool
|
||||
want int
|
||||
}{
|
||||
{"", false, def},
|
||||
{"0", true, 0},
|
||||
{" 500 ", true, 500},
|
||||
{"4000", true, max},
|
||||
{"4001", true, def},
|
||||
{"-1", true, def},
|
||||
{"abc", true, def},
|
||||
{"", true, def},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := notifyLimitValue(tc.v, tc.ok, def, max); got != tc.want {
|
||||
t.Errorf("notifyLimitValue(%q, %v) = %d, want %d", tc.v, tc.ok, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3454,6 +3454,7 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
if concLimit == 0 {
|
||||
concLimit = defaultConcurrencyLimit // when off, still echo a sane default to the UI
|
||||
}
|
||||
limits := notifyLimits(s.m.pg)
|
||||
return map[string]any{
|
||||
"traffic_capture": s.m.TrafficEnabled(),
|
||||
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
|
||||
@@ -3483,6 +3484,9 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
"notify_enabled": s.m.pg.GetBool(settingNotifyEnabled, true),
|
||||
"notify_public_base_url": notifyPublicBaseURL(s.m.pg),
|
||||
"notify_digest_interval_min": notifyDigestIntervalMin(s.m.pg),
|
||||
// Render limits of IM messages; 0 = no truncation.
|
||||
"notify_summary_max_runes": limits.SummaryRunes,
|
||||
"notify_assets_max_shown": limits.AssetsShown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3555,6 +3559,9 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
NotifyEnabled *bool `json:"notify_enabled"`
|
||||
NotifyBaseURL *string `json:"notify_public_base_url"`
|
||||
NotifyDigestMins *int `json:"notify_digest_interval_min"`
|
||||
// Render limits of IM messages: summary characters (0-4000) and asset names listed (0-50); 0 = no truncation.
|
||||
NotifySummaryMaxRunes *int `json:"notify_summary_max_runes"`
|
||||
NotifyAssetsMaxShown *int `json:"notify_assets_max_shown"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
@@ -3610,6 +3617,26 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifySummaryMaxRunes != nil {
|
||||
if *req.NotifySummaryMaxRunes < 0 || *req.NotifySummaryMaxRunes > notifySummaryMaxRunesCap {
|
||||
writeErr(w, 400, fmt.Sprintf("summary limit must be between 0 and %d characters (0 = no truncation)", notifySummaryMaxRunesCap))
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNotifySummaryMaxRunes, strconv.Itoa(*req.NotifySummaryMaxRunes)); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifyAssetsMaxShown != nil {
|
||||
if *req.NotifyAssetsMaxShown < 0 || *req.NotifyAssetsMaxShown > notifyAssetsMaxShownCap {
|
||||
writeErr(w, 400, fmt.Sprintf("asset limit must be between 0 and %d (0 = no truncation)", notifyAssetsMaxShownCap))
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNotifyAssetsMaxShown, strconv.Itoa(*req.NotifyAssetsMaxShown)); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Workers != nil {
|
||||
if err := s.m.SetWorkers(*req.Workers); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
|
||||
@@ -32,6 +32,18 @@ import { ConfigField, FilterSummary } from "./_components/channel-form";
|
||||
import { DeliveryList } from "./_components/delivery-list";
|
||||
import { formatBacklog, StatTile } from "./_components/stat-tile";
|
||||
|
||||
// Upper bounds of the two message render limits. They mirror the backend, which answers 400 outside [0, max].
|
||||
const SUMMARY_LIMIT_MAX = 4000;
|
||||
const ASSETS_LIMIT_MAX = 50;
|
||||
|
||||
// limitInvalid reports whether a limit input holds anything other than a whole number in [0, max].
|
||||
// Empty is valid: it is not submitted, so the stored value stays as it is.
|
||||
function limitInvalid(raw: string, max: number): boolean {
|
||||
if (raw.trim() === "") return false;
|
||||
const n = Number(raw);
|
||||
return !Number.isInteger(n) || n < 0 || n > max;
|
||||
}
|
||||
|
||||
// This page only orchestrates: loading data, holding form state, calling the API.
|
||||
// Field definitions and parsing live in _components/channel-fields.ts, controls and the filter summary in
|
||||
// _components/channel-form.tsx, and the delivery log in _components/delivery-list.tsx —
|
||||
@@ -50,6 +62,8 @@ export default function NotifyPage() {
|
||||
const [globalSaving, setGlobalSaving] = React.useState(false);
|
||||
const [baseURL, setBaseURL] = React.useState("");
|
||||
const [digestMin, setDigestMin] = React.useState("");
|
||||
const [summaryMax, setSummaryMax] = React.useState("");
|
||||
const [assetsMax, setAssetsMax] = React.useState("");
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
api
|
||||
@@ -60,6 +74,14 @@ export default function NotifyPage() {
|
||||
setDigestMin(m.digest_interval_min);
|
||||
})
|
||||
.catch((e) => toast.error("Failed to load notification settings: " + (e as Error).message));
|
||||
// The two render limits are not part of /notify/meta; they are only exposed through /api/settings.
|
||||
api
|
||||
.settings()
|
||||
.then((s) => {
|
||||
setSummaryMax(String(s.notify_summary_max_runes ?? ""));
|
||||
setAssetsMax(String(s.notify_assets_max_shown ?? ""));
|
||||
})
|
||||
.catch((e) => toast.error(`Failed to load message limits: ${(e as Error).message}`));
|
||||
// Channel-list load failures must be reported: failing silently would render as "no channels at all",
|
||||
// and users would think their config was lost — scarier than a straight-up error.
|
||||
api
|
||||
@@ -244,11 +266,23 @@ export default function NotifyPage() {
|
||||
}
|
||||
|
||||
async function saveGlobal() {
|
||||
// Checked before anything is sent: the backend applies the fields of one PUT in order,
|
||||
// so a 400 on a limit would leave the URL and digest interval already saved.
|
||||
if (limitInvalid(summaryMax, SUMMARY_LIMIT_MAX)) {
|
||||
toast.error(`Summary length must be a whole number between 0 and ${SUMMARY_LIMIT_MAX}`);
|
||||
return;
|
||||
}
|
||||
if (limitInvalid(assetsMax, ASSETS_LIMIT_MAX)) {
|
||||
toast.error(`Assets listed must be a whole number between 0 and ${ASSETS_LIMIT_MAX}`);
|
||||
return;
|
||||
}
|
||||
setGlobalSaving(true);
|
||||
try {
|
||||
const patch: Record<string, unknown> = { notify_public_base_url: baseURL.trim() };
|
||||
const n = Number(digestMin);
|
||||
if (Number.isFinite(n) && n > 0) patch.notify_digest_interval_min = n;
|
||||
if (summaryMax.trim() !== "") patch.notify_summary_max_runes = Number(summaryMax);
|
||||
if (assetsMax.trim() !== "") patch.notify_assets_max_shown = Number(assetsMax);
|
||||
await api.setSettings(patch);
|
||||
toast.success("Saved");
|
||||
load();
|
||||
@@ -331,6 +365,40 @@ export default function NotifyPage() {
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">Only applies to channels in "digest" mode.</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-summary-max">Summary length (characters)</Label>
|
||||
<Input
|
||||
id="n-summary-max"
|
||||
type="number"
|
||||
min={0}
|
||||
max={SUMMARY_LIMIT_MAX}
|
||||
step={1}
|
||||
placeholder="120"
|
||||
value={summaryMax}
|
||||
onChange={(e) => setSummaryMax(e.target.value)}
|
||||
aria-invalid={limitInvalid(summaryMax, SUMMARY_LIMIT_MAX)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Longest finding summary shown in a message, 0–{SUMMARY_LIMIT_MAX}. 0 = no truncation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-assets-max">Assets listed per finding</Label>
|
||||
<Input
|
||||
id="n-assets-max"
|
||||
type="number"
|
||||
min={0}
|
||||
max={ASSETS_LIMIT_MAX}
|
||||
step={1}
|
||||
placeholder="3"
|
||||
value={assetsMax}
|
||||
onChange={(e) => setAssetsMax(e.target.value)}
|
||||
aria-invalid={limitInvalid(assetsMax, ASSETS_LIMIT_MAX)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Most asset names listed for a finding, 0–{ASSETS_LIMIT_MAX}. 0 = no truncation (all assets listed).
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Button onClick={saveGlobal} disabled={globalSaving}>
|
||||
Save global settings
|
||||
|
||||
@@ -957,6 +957,9 @@ export interface Settings {
|
||||
notify_enabled?: boolean; // master push switch, on by default; one-click kill switch for maintenance windows
|
||||
notify_public_base_url?: string; // external base URL for vulnerability detail links; empty = messages carry no link
|
||||
notify_digest_interval_min?: number; // digest mode period (minutes), default 30
|
||||
// Render limits of IM messages; 0 = no truncation. The backend answers 400 outside the bounds.
|
||||
notify_summary_max_runes?: number; // summary characters per finding, 0-4000, default 120
|
||||
notify_assets_max_shown?: number; // asset names listed per finding, 0-50, default 3
|
||||
}
|
||||
|
||||
// ---- Vulnerability IM push ----
|
||||
|
||||
Reference in New Issue
Block a user