This commit is contained in:
+83
-10
@@ -8,12 +8,12 @@ import (
|
||||
"github.com/honeypeer/cli-viz/internal/viz"
|
||||
)
|
||||
|
||||
func renderDetailContent(t Theme, item *viz.FeedItem, inc *viz.Incident, attack *viz.PublicAttack, mod *viz.ModerationEvent, loading bool, errMsg string, width int) string {
|
||||
func renderDetailContent(t Theme, item *viz.FeedItem, inc *viz.Incident, attack *viz.PublicAttack, mod *viz.ModerationEvent, modAttacks []viz.PublicAttack, attacksLoading bool, attacksErr string, loading bool, errMsg string, width int) string {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("Event detail")
|
||||
if loading {
|
||||
if loading && attack == nil && mod == nil {
|
||||
return title + "\n\n" + t.MutedStyle().Render("Loading full record…")
|
||||
}
|
||||
if errMsg != "" {
|
||||
if errMsg != "" && attack == nil && mod == nil {
|
||||
return title + "\n\n" + lipgloss.NewStyle().Foreground(t.Attack).Render(errMsg)
|
||||
}
|
||||
if item == nil && attack == nil && inc == nil && mod == nil {
|
||||
@@ -28,7 +28,7 @@ func renderDetailContent(t Theme, item *viz.FeedItem, inc *viz.Incident, attack
|
||||
return b.String()
|
||||
}
|
||||
if mod != nil {
|
||||
renderModerationDetail(&b, t, *mod, width)
|
||||
renderModerationDetail(&b, t, *mod, modAttacks, attacksLoading, attacksErr, width)
|
||||
return b.String()
|
||||
}
|
||||
if inc != nil {
|
||||
@@ -42,7 +42,7 @@ func renderDetailContent(t Theme, item *viz.FeedItem, inc *viz.Incident, attack
|
||||
}
|
||||
|
||||
func renderDetail(t Theme, item *viz.FeedItem, attack *viz.PublicAttack, loading bool, errMsg string, width int) string {
|
||||
return renderDetailContent(t, item, nil, attack, nil, loading, errMsg, width)
|
||||
return renderDetailContent(t, item, nil, attack, nil, nil, false, "", loading, errMsg, width)
|
||||
}
|
||||
|
||||
func renderFeedItemDetail(b *strings.Builder, t Theme, item viz.FeedItem, width int) {
|
||||
@@ -96,7 +96,9 @@ func renderFeedItemDetail(b *strings.Builder, t Theme, item viz.FeedItem, width
|
||||
}
|
||||
|
||||
func writeInteraction(b *strings.Builder, ix map[string]interface{}, raw string, width int) {
|
||||
raw = viz.SanitizeDisplayLimit(raw, viz.MaxCodeBlock)
|
||||
if len(ix) > 0 {
|
||||
ix = viz.SanitizeInteraction(ix)
|
||||
b.WriteString("\n")
|
||||
b.WriteString(viz.FormatInteraction(ix, width))
|
||||
return
|
||||
@@ -113,6 +115,8 @@ func writeInteraction(b *strings.Builder, ix map[string]interface{}, raw string,
|
||||
}
|
||||
|
||||
func renderAttackDetail(b *strings.Builder, t Theme, a *viz.PublicAttack, width int) {
|
||||
safe := viz.SanitizePublicAttack(*a)
|
||||
a = &safe
|
||||
b.WriteString(fmt.Sprintf(" ID: %s\n", a.ID))
|
||||
b.WriteString(fmt.Sprintf(" Time: %s\n", viz.FormatTime(a.Timestamp)))
|
||||
b.WriteString(fmt.Sprintf(" IP: %s\n", viz.DisplayIP(a.IP, a.IPMasked)))
|
||||
@@ -143,11 +147,45 @@ func renderAttackDetail(b *strings.Builder, t Theme, a *viz.PublicAttack, width
|
||||
b.WriteString(fmt.Sprintf(" Co-cons: %d\n", a.CoConspiratorCount))
|
||||
}
|
||||
if a.BlobCount > 0 {
|
||||
b.WriteString(fmt.Sprintf(" Blobs: %d\n", a.BlobCount))
|
||||
b.WriteString(fmt.Sprintf(" Blobs: %d (content redacted)\n", a.BlobCount))
|
||||
}
|
||||
if a.AdaptiveTarpit {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(t.Teal).Render(" Trap: adaptive tarpit armed") + "\n")
|
||||
}
|
||||
if ix := attackInteractionMap(a); ix != nil && interactionTruthy(ix, "exploitUpload") {
|
||||
line := " Exploit: upload"
|
||||
if reason, ok := ix["exploitUploadReason"].(string); ok && strings.TrimSpace(reason) != "" {
|
||||
line += " · " + reason
|
||||
}
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(t.Attack).Render(line) + "\n")
|
||||
}
|
||||
writeInteraction(b, a.Interaction, a.InteractionRaw, width)
|
||||
}
|
||||
|
||||
func attackInteractionMap(a *viz.PublicAttack) map[string]interface{} {
|
||||
if len(a.Interaction) > 0 {
|
||||
return a.Interaction
|
||||
}
|
||||
return viz.ParseInteractionJSON(a.InteractionRaw)
|
||||
}
|
||||
|
||||
func interactionTruthy(ix map[string]interface{}, key string) bool {
|
||||
v, ok := ix[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
return x == "true" || x == "1" || x == "yes"
|
||||
case float64:
|
||||
return x != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func renderIncidentDetail(b *strings.Builder, t Theme, inc viz.Incident, width int) {
|
||||
ip := viz.DisplayIP(inc.IP, inc.IPMasked)
|
||||
b.WriteString(fmt.Sprintf(" Kind: %s\n", inc.Kind))
|
||||
@@ -209,7 +247,9 @@ func renderIncidentDetail(b *strings.Builder, t Theme, inc viz.Incident, width i
|
||||
}
|
||||
}
|
||||
|
||||
func renderModerationDetail(b *strings.Builder, t Theme, m viz.ModerationEvent, width int) {
|
||||
func renderModerationDetail(b *strings.Builder, t Theme, m viz.ModerationEvent, attacks []viz.PublicAttack, attacksLoading bool, attacksErr string, width int) {
|
||||
m = viz.SanitizeModerationEvent(m)
|
||||
b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render(" Reputation") + "\n")
|
||||
b.WriteString(fmt.Sprintf(" ID: %s\n", m.ID))
|
||||
b.WriteString(fmt.Sprintf(" Time: %s\n", viz.FormatTime(m.Timestamp)))
|
||||
b.WriteString(fmt.Sprintf(" IP: %s\n", viz.DisplayIP(m.IP, m.IPMasked)))
|
||||
@@ -227,18 +267,51 @@ func renderModerationDetail(b *strings.Builder, t Theme, m viz.ModerationEvent,
|
||||
if m.Action != "" {
|
||||
b.WriteString(fmt.Sprintf(" Action: %s\n", m.Action))
|
||||
}
|
||||
if m.AttackID != "" {
|
||||
b.WriteString(fmt.Sprintf(" Attack: %s\n", m.AttackID))
|
||||
}
|
||||
if m.CoConspiratorCount > 0 {
|
||||
b.WriteString(fmt.Sprintf(" Co-cons: %d\n", m.CoConspiratorCount))
|
||||
}
|
||||
if m.SubnetBlocked {
|
||||
b.WriteString(fmt.Sprintf(" Subnet: %s\n", m.SubnetCidrMasked))
|
||||
}
|
||||
|
||||
ids := viz.ModerationAttackIDs(m)
|
||||
if len(ids) > 0 || attacksLoading || attacksErr != "" {
|
||||
b.WriteString("\n")
|
||||
section := "Attack activity"
|
||||
if len(attacks) > 1 {
|
||||
section = fmt.Sprintf("Attacks (%d)", len(attacks))
|
||||
} else if len(ids) > 1 {
|
||||
section = fmt.Sprintf("Attacks (%d)", len(ids))
|
||||
}
|
||||
b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render(" "+section) + "\n")
|
||||
if attacksLoading {
|
||||
b.WriteString(t.MutedStyle().Render(" Loading attack details…") + "\n")
|
||||
} else if attacksErr != "" {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(t.Attack).Render(" "+attacksErr) + "\n")
|
||||
}
|
||||
for i := range attacks {
|
||||
safe := viz.SanitizePublicAttack(attacks[i])
|
||||
a := safe
|
||||
if len(attacks) > 1 {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(t.MutedStyle().Render(fmt.Sprintf(" ── Attack %d of %d ──", i+1, len(attacks))) + "\n")
|
||||
}
|
||||
renderAttackDetail(b, t, &a, width)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render(" Record") + "\n")
|
||||
if m.Source != "" {
|
||||
b.WriteString(fmt.Sprintf(" Source: %s\n", m.Source))
|
||||
}
|
||||
if m.Reason != "" {
|
||||
writeWrappedField(b, "Reason:", m.Reason, width, 12)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWrappedField(b *strings.Builder, label, value string, width, labelWidth int) {
|
||||
value = viz.SanitizeDisplayLimit(value, viz.MaxDisplayField)
|
||||
if width < 20 {
|
||||
width = 80
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/honeypeer/cli-viz/internal/config"
|
||||
"github.com/honeypeer/cli-viz/internal/viz"
|
||||
)
|
||||
|
||||
func TestDetailViewportAttackWithGarbage(t *testing.T) {
|
||||
m := NewModel(config.Config{Buffer: 100})
|
||||
m.width = 100
|
||||
m.height = 40
|
||||
m.initDetailViewport()
|
||||
|
||||
garbage := "POST\x1b[2J\x00" + strings.Repeat("A", 5000)
|
||||
attack := &viz.PublicAttack{
|
||||
ID: "attack_test",
|
||||
Service: "HTTP",
|
||||
Timestamp: 1719901862000,
|
||||
IPMasked: "1.2.x.x",
|
||||
Summary: garbage,
|
||||
Interaction: map[string]interface{}{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"url": "/\x1b[31madmin",
|
||||
"body": garbage,
|
||||
},
|
||||
InteractionRaw: `{"type":"http","body":"bad\u0000data"}`,
|
||||
}
|
||||
m.detailAttack = attack
|
||||
content := m.detailViewportContent()
|
||||
if strings.Contains(content, "\x1b[") {
|
||||
t.Fatalf("ansi leaked into viewport: %q", content[:min(200, len(content))])
|
||||
}
|
||||
if !utf8.ValidString(content) {
|
||||
t.Fatal("viewport content invalid utf8")
|
||||
}
|
||||
if !strings.Contains(content, "attack_test") {
|
||||
t.Fatal("expected attack id in content")
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
+66
-2
@@ -34,6 +34,10 @@ type moderationDetailMsg struct {
|
||||
mod viz.ModerationEvent
|
||||
err error
|
||||
}
|
||||
type moderationAttacksMsg struct {
|
||||
attacks []viz.PublicAttack
|
||||
err error
|
||||
}
|
||||
type historyLoadedMsg struct {
|
||||
items []viz.FeedItem
|
||||
total int
|
||||
@@ -115,6 +119,9 @@ type Model struct {
|
||||
detailIncident *viz.Incident
|
||||
detailAttack *viz.PublicAttack
|
||||
detailModeration *viz.ModerationEvent
|
||||
detailModAttacks []viz.PublicAttack
|
||||
detailAttacksLoading bool
|
||||
detailAttacksErr string
|
||||
detailViewport viewport.Model
|
||||
detailLoading bool
|
||||
detailErr string
|
||||
@@ -427,7 +434,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.detailAttack = nil
|
||||
} else {
|
||||
m.detailErr = ""
|
||||
m.detailAttack = &msg.attack
|
||||
cp := viz.SanitizePublicAttack(msg.attack)
|
||||
m.detailAttack = &cp
|
||||
}
|
||||
m.refreshDetailViewport()
|
||||
|
||||
@@ -436,10 +444,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.err != nil {
|
||||
m.detailErr = msg.err.Error()
|
||||
m.detailModeration = nil
|
||||
m.detailModAttacks = nil
|
||||
m.detailAttacksLoading = false
|
||||
m.detailAttacksErr = ""
|
||||
} else {
|
||||
m.detailErr = ""
|
||||
cp := msg.mod
|
||||
cp := viz.SanitizeModerationEvent(msg.mod)
|
||||
m.detailModeration = &cp
|
||||
m.detailModAttacks = nil
|
||||
m.detailAttacksErr = ""
|
||||
if cmd := m.loadModerationAttacks(cp); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
} else {
|
||||
m.detailAttacksLoading = false
|
||||
}
|
||||
}
|
||||
m.refreshDetailViewport()
|
||||
|
||||
case moderationAttacksMsg:
|
||||
m.detailAttacksLoading = false
|
||||
if len(msg.attacks) == 0 && msg.err != nil {
|
||||
m.detailAttacksErr = msg.err.Error()
|
||||
} else {
|
||||
m.detailAttacksErr = ""
|
||||
m.detailModAttacks = msg.attacks
|
||||
}
|
||||
m.refreshDetailViewport()
|
||||
|
||||
@@ -727,6 +755,9 @@ func (m *Model) clearDetail() {
|
||||
m.detailIncident = nil
|
||||
m.detailAttack = nil
|
||||
m.detailModeration = nil
|
||||
m.detailModAttacks = nil
|
||||
m.detailAttacksLoading = false
|
||||
m.detailAttacksErr = ""
|
||||
m.detailErr = ""
|
||||
m.detailLoading = false
|
||||
m.detailViewport.GotoTop()
|
||||
@@ -922,6 +953,39 @@ func fetchAttackDetail(api *transport.APIClient, id string) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) loadModerationAttacks(mod viz.ModerationEvent) tea.Cmd {
|
||||
if m.api == nil {
|
||||
return nil
|
||||
}
|
||||
ids := viz.ModerationAttackIDs(mod)
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
m.detailAttacksLoading = true
|
||||
return fetchModerationAttacks(m.api, ids)
|
||||
}
|
||||
|
||||
func fetchModerationAttacks(api *transport.APIClient, ids []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
attacks := make([]viz.PublicAttack, 0, len(ids))
|
||||
var firstErr error
|
||||
for _, id := range ids {
|
||||
a, err := api.AttackDetail(id)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
attacks = append(attacks, viz.SanitizePublicAttack(a))
|
||||
}
|
||||
if len(attacks) == 0 && firstErr != nil {
|
||||
return moderationAttacksMsg{err: firstErr}
|
||||
}
|
||||
return moderationAttacksMsg{attacks: attacks}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchModerationDetail(api *transport.APIClient, id string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
mod, err := api.ModerationDetail(id)
|
||||
|
||||
@@ -80,7 +80,7 @@ func (m Model) detailViewportContent() string {
|
||||
if innerW < 20 {
|
||||
innerW = 20
|
||||
}
|
||||
return renderDetailContent(m.theme, m.detailItem, m.detailIncident, m.detailAttack, m.detailModeration, m.detailLoading, m.detailErr, innerW)
|
||||
return renderDetailContent(m.theme, m.detailItem, m.detailIncident, m.detailAttack, m.detailModeration, m.detailModAttacks, m.detailAttacksLoading, m.detailAttacksErr, m.detailLoading, m.detailErr, innerW)
|
||||
}
|
||||
|
||||
func (m *Model) initDetailViewport() {
|
||||
|
||||
@@ -14,6 +14,7 @@ func FormatInteraction(ix map[string]interface{}, width int) string {
|
||||
if len(ix) == 0 {
|
||||
return ""
|
||||
}
|
||||
ix = SanitizeInteraction(ix)
|
||||
if width < 40 {
|
||||
width = 80
|
||||
}
|
||||
@@ -57,7 +58,7 @@ func ParseInteractionJSON(raw string) map[string]interface{} {
|
||||
}
|
||||
var ix map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &ix); err == nil && len(ix) > 0 {
|
||||
return ix
|
||||
return SanitizeInteraction(ix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -511,7 +512,7 @@ func ixScalarString(v interface{}) string {
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
return SanitizeDisplayLimit(x, MaxCodeBlock)
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return strconv.FormatInt(int64(x), 10)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package viz
|
||||
|
||||
import "strings"
|
||||
|
||||
// ModerationAttackIDs returns linked attack ids from a moderation record.
|
||||
// Prefer server-resolved attackIds; fall back to client hints (matches web viz).
|
||||
func ModerationAttackIDs(m ModerationEvent) []string {
|
||||
if len(m.AttackIds) > 0 {
|
||||
out := make([]string, 0, len(m.AttackIds))
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range m.AttackIds {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return ResolveModerationAttackIds(m)
|
||||
}
|
||||
|
||||
// ResolveModerationAttackIds extracts attack id hints without server-side history scan.
|
||||
func ResolveModerationAttackIds(m ModerationEvent) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var ids []string
|
||||
add := func(id string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
add(m.AttackID)
|
||||
modID := strings.TrimSpace(m.ID)
|
||||
if strings.HasPrefix(modID, "mod_attack_") {
|
||||
add(strings.TrimPrefix(modID, "mod_"))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package viz
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveModerationAttackIds(t *testing.T) {
|
||||
ids := ResolveModerationAttackIds(ModerationEvent{
|
||||
ID: "mod_attack_1783075711339_rdmk5pwid",
|
||||
AttackID: "attack_other",
|
||||
})
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("len = %d, want 2: %v", len(ids), ids)
|
||||
}
|
||||
if ids[0] != "attack_other" {
|
||||
t.Fatalf("first = %q", ids[0])
|
||||
}
|
||||
if ids[1] != "attack_1783075711339_rdmk5pwid" {
|
||||
t.Fatalf("second = %q", ids[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestModerationAttackIDsPrefersServerList(t *testing.T) {
|
||||
ids := ModerationAttackIDs(ModerationEvent{
|
||||
ID: "mod_001",
|
||||
AttackID: "attack_fallback",
|
||||
AttackIds: []string{"attack_a", "attack_b", "attack_a"},
|
||||
})
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(ids))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package viz
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
// Limits keep modal/viewport rendering bounded.
|
||||
MaxDisplayField = 4096
|
||||
MaxSummaryField = 2048
|
||||
MaxCodeBlock = 8192
|
||||
MaxIDField = 160
|
||||
)
|
||||
|
||||
var ansiEscape = regexp.MustCompile(`\x1b(?:\[[0-9;]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))`)
|
||||
|
||||
// SanitizeDisplay strips terminal control sequences and invalid UTF-8 from user-facing text.
|
||||
func SanitizeDisplay(s string) string {
|
||||
return SanitizeDisplayLimit(s, MaxDisplayField)
|
||||
}
|
||||
|
||||
// SanitizeDisplayLimit cleans s and truncates to maxRunes (0 = no truncate).
|
||||
func SanitizeDisplayLimit(s string, maxRunes int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
s = ansiEscape.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
s = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r == '\n' || r == '\t':
|
||||
return r
|
||||
case r == '\r':
|
||||
return -1
|
||||
case unicode.IsControl(r):
|
||||
return ' '
|
||||
case r == '\u200b' || r == '\ufeff':
|
||||
return -1
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, s)
|
||||
s = strings.ReplaceAll(s, "\x1b", "")
|
||||
if maxRunes > 0 {
|
||||
s = Truncate(s, maxRunes)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SanitizeInteraction deep-cleans string values in parsed interaction JSON.
|
||||
func SanitizeInteraction(ix map[string]interface{}) map[string]interface{} {
|
||||
if len(ix) == 0 {
|
||||
return ix
|
||||
}
|
||||
out := make(map[string]interface{}, len(ix))
|
||||
for k, v := range ix {
|
||||
sk := SanitizeDisplayLimit(k, 128)
|
||||
if sk == "" {
|
||||
continue
|
||||
}
|
||||
out[sk] = sanitizeInteractionValue(v, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanitizeInteractionValue(v interface{}, depth int) interface{} {
|
||||
if depth > 12 {
|
||||
return nil
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return SanitizeDisplayLimit(x, MaxCodeBlock)
|
||||
case map[string]interface{}:
|
||||
return SanitizeInteraction(x)
|
||||
case []interface{}:
|
||||
if len(x) > 256 {
|
||||
x = x[:256]
|
||||
}
|
||||
out := make([]interface{}, len(x))
|
||||
for i, item := range x {
|
||||
out[i] = sanitizeInteractionValue(item, depth+1)
|
||||
}
|
||||
return out
|
||||
case json.Number:
|
||||
return x.String()
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizePublicAttack returns a display-safe copy of an attack record.
|
||||
func SanitizePublicAttack(a PublicAttack) PublicAttack {
|
||||
a.ID = SanitizeDisplayLimit(a.ID, MaxIDField)
|
||||
a.Service = SanitizeDisplayLimit(a.Service, 64)
|
||||
a.PeerID = SanitizeDisplayLimit(a.PeerID, MaxIDField)
|
||||
a.PeerPublicKeyHex = SanitizeDisplayLimit(a.PeerPublicKeyHex, MaxIDField)
|
||||
a.IP = SanitizeDisplayLimit(a.IP, 64)
|
||||
a.IPMasked = SanitizeDisplayLimit(a.IPMasked, 64)
|
||||
a.Geo = SanitizeDisplayLimit(a.Geo, 64)
|
||||
a.Summary = SanitizeDisplayLimit(a.Summary, MaxSummaryField)
|
||||
a.InteractionRaw = SanitizeDisplayLimit(a.InteractionRaw, MaxCodeBlock)
|
||||
if len(a.Interaction) > 0 {
|
||||
a.Interaction = SanitizeInteraction(a.Interaction)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// SanitizeModerationEvent returns a display-safe copy of a moderation record.
|
||||
func SanitizeModerationEvent(m ModerationEvent) ModerationEvent {
|
||||
m.ID = SanitizeDisplayLimit(m.ID, MaxIDField)
|
||||
m.IP = SanitizeDisplayLimit(m.IP, 64)
|
||||
m.IPMasked = SanitizeDisplayLimit(m.IPMasked, 64)
|
||||
m.Geo = SanitizeDisplayLimit(m.Geo, 64)
|
||||
m.Action = SanitizeDisplayLimit(m.Action, 128)
|
||||
m.Source = SanitizeDisplayLimit(m.Source, 128)
|
||||
m.Reason = SanitizeDisplayLimit(m.Reason, MaxCodeBlock)
|
||||
m.TierBefore = SanitizeDisplayLimit(m.TierBefore, 32)
|
||||
m.TierAfter = SanitizeDisplayLimit(m.TierAfter, 32)
|
||||
m.AttackID = SanitizeDisplayLimit(m.AttackID, MaxIDField)
|
||||
m.SubnetCidrMasked = SanitizeDisplayLimit(m.SubnetCidrMasked, 64)
|
||||
if len(m.AttackIds) > 0 {
|
||||
ids := make([]string, 0, len(m.AttackIds))
|
||||
for _, id := range m.AttackIds {
|
||||
if id = SanitizeDisplayLimit(id, MaxIDField); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
m.AttackIds = ids
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package viz
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestSanitizeDisplayStripsANSIAndControls(t *testing.T) {
|
||||
in := "hello\x1b[31mred\x1b[0m\x07world\x00\nok\t"
|
||||
out := SanitizeDisplay(in)
|
||||
if strings.Contains(out, "\x1b") {
|
||||
t.Fatalf("ansi not stripped: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "\x00") {
|
||||
t.Fatal("nul not stripped")
|
||||
}
|
||||
if !strings.Contains(out, "hello") || !strings.Contains(out, "world") {
|
||||
t.Fatalf("content lost: %q", out)
|
||||
}
|
||||
if !utf8.ValidString(out) {
|
||||
t.Fatal("invalid utf8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDisplayInvalidUTF8(t *testing.T) {
|
||||
in := "abc\xff\xfeok"
|
||||
out := SanitizeDisplay(in)
|
||||
if !utf8.ValidString(out) {
|
||||
t.Fatalf("invalid utf8: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "ok") {
|
||||
t.Fatalf("expected ok in %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeInteractionNested(t *testing.T) {
|
||||
ix := map[string]interface{}{
|
||||
"type": "http",
|
||||
"body": "POST\x1b[2Jpayload",
|
||||
"headers": map[string]interface{}{
|
||||
"x-test": "val\x00ue",
|
||||
},
|
||||
}
|
||||
out := SanitizeInteraction(ix)
|
||||
body := out["body"].(string)
|
||||
if strings.Contains(body, "\x1b") {
|
||||
t.Fatalf("body not cleaned: %q", body)
|
||||
}
|
||||
hdr := out["headers"].(map[string]interface{})
|
||||
if strings.Contains(hdr["x-test"].(string), "\x00") {
|
||||
t.Fatal("header not cleaned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePublicAttack(t *testing.T) {
|
||||
a := SanitizePublicAttack(PublicAttack{
|
||||
ID: "attack_1\x1b",
|
||||
Summary: strings.Repeat("x", MaxSummaryField+100),
|
||||
Interaction: map[string]interface{}{
|
||||
"type": "ssh",
|
||||
"raw": "\x1b[?1049h",
|
||||
},
|
||||
})
|
||||
if strings.Contains(a.ID, "\x1b") {
|
||||
t.Fatal("id not cleaned")
|
||||
}
|
||||
if len([]rune(a.Summary)) > MaxSummaryField {
|
||||
t.Fatal("summary not truncated")
|
||||
}
|
||||
raw := a.Interaction["raw"].(string)
|
||||
if strings.Contains(raw, "\x1b") {
|
||||
t.Fatal("interaction not cleaned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInteractionJSONSanitizes(t *testing.T) {
|
||||
raw := `{"type":"tcp","initialData":"\u0000\u001b[2Jbin"}`
|
||||
ix := ParseInteractionJSON(raw)
|
||||
if ix == nil {
|
||||
t.Fatal("nil parse")
|
||||
}
|
||||
data, _ := ix["initialData"].(string)
|
||||
if strings.Contains(data, "\x00") || strings.Contains(data, "\x1b") {
|
||||
t.Fatalf("not sanitized: %q", data)
|
||||
}
|
||||
}
|
||||
@@ -81,9 +81,12 @@ type ModerationEvent struct {
|
||||
GreylistScore float64 `json:"greylistScore,omitempty"`
|
||||
TierBefore string `json:"tierBefore"`
|
||||
TierAfter string `json:"tierAfter"`
|
||||
Action string `json:"action"`
|
||||
Geo string `json:"geo,omitempty"`
|
||||
AttackID string `json:"attackId,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Geo string `json:"geo,omitempty"`
|
||||
AttackID string `json:"attackId,omitempty"`
|
||||
AttackIds []string `json:"attackIds,omitempty"`
|
||||
CoConspiratorCount int `json:"coConspiratorCount,omitempty"`
|
||||
SubnetBlocked bool `json:"subnetBlocked,omitempty"`
|
||||
SubnetCidrMasked string `json:"subnetCidrMasked,omitempty"`
|
||||
@@ -116,6 +119,7 @@ type PublicAttack struct {
|
||||
BlobCount int `json:"blobCount,omitempty"`
|
||||
Interaction map[string]interface{} `json:"interaction,omitempty"`
|
||||
InteractionRaw string `json:"interactionRaw,omitempty"`
|
||||
AdaptiveTarpit bool `json:"adaptiveTarpit,omitempty"`
|
||||
CoConspiratorCount int `json:"coConspiratorCount,omitempty"`
|
||||
ScoreAfter float64 `json:"scoreAfter,omitempty"`
|
||||
ScoreDelta float64 `json:"scoreDelta,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user