From 21bda80f889ef16a1a5165ff6cfdf605b48f5a2c Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 6 Nov 2026 19:57:23 -0800 Subject: [PATCH] add ipabusedb-docker-ssh: report sshd brute-force attempts from docker logs to AbuseIPDB --- ipabusedb-docker-ssh/.gitignore | 2 + ipabusedb-docker-ssh/Makefile | 23 ++ ipabusedb-docker-ssh/README.md | 56 +++ ipabusedb-docker-ssh/example_parser_script.py | 76 ++++ ipabusedb-docker-ssh/go.mod | 3 + ipabusedb-docker-ssh/loglines_example.txt | 13 + ipabusedb-docker-ssh/main.go | 366 ++++++++++++++++++ ipabusedb-docker-ssh/main_test.go | 119 ++++++ 8 files changed, 658 insertions(+) create mode 100644 ipabusedb-docker-ssh/.gitignore create mode 100644 ipabusedb-docker-ssh/Makefile create mode 100644 ipabusedb-docker-ssh/README.md create mode 100644 ipabusedb-docker-ssh/example_parser_script.py create mode 100644 ipabusedb-docker-ssh/go.mod create mode 100644 ipabusedb-docker-ssh/loglines_example.txt create mode 100644 ipabusedb-docker-ssh/main.go create mode 100644 ipabusedb-docker-ssh/main_test.go diff --git a/ipabusedb-docker-ssh/.gitignore b/ipabusedb-docker-ssh/.gitignore new file mode 100644 index 0000000..33d94c0 --- /dev/null +++ b/ipabusedb-docker-ssh/.gitignore @@ -0,0 +1,2 @@ +/ipabusedb-docker-ssh +/reports.csv diff --git a/ipabusedb-docker-ssh/Makefile b/ipabusedb-docker-ssh/Makefile new file mode 100644 index 0000000..b299a59 --- /dev/null +++ b/ipabusedb-docker-ssh/Makefile @@ -0,0 +1,23 @@ +BIN := ipabusedb-docker-ssh + +.PHONY: build test lint fmt fmt-check check clean + +build: + go build -o $(BIN) . + +test: + go test ./... + +fmt: + gofmt -w *.go + +fmt-check: + @test -z "$$(gofmt -l .)" || { gofmt -l .; echo "gofmt: files need formatting"; exit 1; } + +lint: fmt-check + go vet ./... + +check: lint test build + +clean: + rm -f $(BIN) diff --git a/ipabusedb-docker-ssh/README.md b/ipabusedb-docker-ssh/README.md new file mode 100644 index 0000000..ecd6b11 --- /dev/null +++ b/ipabusedb-docker-ssh/README.md @@ -0,0 +1,56 @@ +# ipabusedb-docker-ssh + +Reads the logs of a dockerised sshd with `docker logs -t`, extracts +brute-force / invalid-user login attempts, and emits a CSV in the format +expected by the [AbuseIPDB bulk-report +endpoint](https://www.abuseipdb.com/bulk-report). Optionally submits it. + +What gets reported: + +- `Invalid user X from IP port N` +- `User X from IP not allowed because not listed in AllowUsers` +- `Failed password for invalid user X from IP port N` +- `Connection closed by invalid user X IP port N` and + `Disconnected from invalid user X IP port N` + +Attempts with the username `git` are excluded (so people poking a git +host with `git@` are not reported). Private, loopback and link-local +source addresses are skipped. Each IP appears once in the CSV, dated with +its most recent attempt and annotated with the number of matching lines. +Categories are `18,22` (Brute-Force, SSH). + +## Configuration + +| Variable | Purpose | +| ----------------------- | ------------------------------------------------ | +| `DOCKER_CONTAINER_NAME` | container whose logs are read (required) | +| `IPABUSEDB_API_KEY` | AbuseIPDB API key (required only with `--submit`) | + +Flags: + +| Flag | Purpose | +| --------------- | --------------------------------------------------------- | +| `--since 24h` | only consider lines newer than now minus this duration | +| `-o FILE` | write the CSV to `FILE` instead of stdout | +| `--input FILE` | parse `FILE` (or `-` for stdin) instead of `docker logs` | +| `--submit` | POST the CSV to the AbuseIPDB bulk-report endpoint | + +## Usage + +``` +$ make build +$ export DOCKER_CONTAINER_NAME=sshd IPABUSEDB_API_KEY=... +$ ./ipabusedb-docker-ssh --since 24h -o reports.csv +$ ./ipabusedb-docker-ssh --since 24h --submit +``` + +Submitting manually with curl instead of `--submit`: + +``` +$ ./ipabusedb-docker-ssh --since 24h > reports.csv && curl https://api.abuseipdb.com/api/v2/bulk-report -F csv=@reports.csv -H "Key: $IPABUSEDB_API_KEY" +``` + +`make check` runs vet, the tests and the build. `loglines_example.txt` +holds sample `docker logs -t` output used by the tests; +`example_parser_script.py` is the reference script from AbuseIPDB that +this program replaces. diff --git a/ipabusedb-docker-ssh/example_parser_script.py b/ipabusedb-docker-ssh/example_parser_script.py new file mode 100644 index 0000000..77ba3de --- /dev/null +++ b/ipabusedb-docker-ssh/example_parser_script.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +import os +import sys +import argparse +import re +import csv +from datetime import datetime, timezone +import pytz + +def main(arguments): + + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('infile', help="Input file", type=argparse.FileType('r')) + parser.add_argument('-o', '--outfile', help="Output file", + default=sys.stdout, type=argparse.FileType('w')) + + args = parser.parse_args(arguments) + + # Define field names. + fieldnames = ['IP', 'Categories', 'Comment', 'ReportDate'] + # Begin CSV output. + writer = csv.DictWriter(args.outfile, fieldnames=fieldnames) + writer.writeheader() + + # Initialize empty list to hold addresses + ipv4_addresses = list() + + for line in args.infile: + # !! Match this format to your system's format. + timestamp = "([a-zA-Z]+\s+[0-9]+ [0-9]+:[0-9]+:[0-9]+)" + ipv4 = "([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})" + comment = "(Invalid user [a-zA-Z0-9]+ from " + ipv4 + " port [0-9]+)" + + # The regex of the line we're looking for, built up from component regexps. + combined_re = timestamp + " .* " + comment + + # Run the regexp. + matches = re.findall(combined_re, line) + # If this line is in the format we're looking for, + if matches: + # Pull the tuple out of the list. + matches_flat = matches[0] + + # Remove duplicate addresses from the report. + if matches_flat[2] not in ipv4_addresses: + ipv4_addresses.append(matches_flat[2]) + else: + continue + + ### !!! You may need to update this. ### + # Parse log datetime to Python datetime object so we can update the timezone. + # The format string should must your log files. Here we use the default in Debian/Redhat distros. + attack_datetime = datetime.strptime(matches_flat[0], '%b %d %H:%M:%S') + # Assume year is the current year. + attack_datetime = attack_datetime.replace(datetime.now().year) + # !! Set tzinfo to your system timezone using timezone. + my_tz = pytz.timezone('America/New_York') + attack_datetime = attack_datetime.replace(tzinfo=my_tz) + + # Format to ISO 8601 to make it universal and portable. + attack_datetime_iso = attack_datetime.isoformat() + + # We'll add the categories column statically at this step. + # Output as a CSV row. + writer.writerow({ + 'IP': matches_flat[2], + 'Categories': "18,22", + 'Comment': matches_flat[1], + 'ReportDate': attack_datetime_iso + }) + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) \ No newline at end of file diff --git a/ipabusedb-docker-ssh/go.mod b/ipabusedb-docker-ssh/go.mod new file mode 100644 index 0000000..db6f23b --- /dev/null +++ b/ipabusedb-docker-ssh/go.mod @@ -0,0 +1,3 @@ +module sneak.berlin/go/hacks/ipabusedb-docker-ssh + +go 1.26.5 diff --git a/ipabusedb-docker-ssh/loglines_example.txt b/ipabusedb-docker-ssh/loglines_example.txt new file mode 100644 index 0000000..7e8ddf3 --- /dev/null +++ b/ipabusedb-docker-ssh/loglines_example.txt @@ -0,0 +1,13 @@ +2026-10-06T12:04:24.554951297Z User root from 41.93.28.11 not allowed because not listed in AllowUsers +2026-10-06T12:04:24.595412750Z Connection closed by invalid user root 41.93.28.11 port 51904 [preauth] +2026-10-06T12:04:24.687263829Z Invalid user production from 45.191.4.252 port 38924 +2026-10-06T12:04:24.927218990Z Connection closed by invalid user production 45.191.4.252 port 38924 [preauth] +2026-10-06T12:04:28.005424065Z Timeout before authentication for connection from 95.78.250.88 to 172.19.0.29, pid = 307434 +2026-10-06T12:04:29.009224164Z User root from 164.77.161.180 not allowed because not listed in AllowUsers +2026-10-06T12:04:29.494569018Z Connection closed by invalid user root 164.77.161.180 port 33916 [preauth] +2026-10-06T12:04:30.222573149Z Invalid user production from 212.128.3.152 port 60626 +2026-10-06T12:04:30.266239421Z Connection closed by invalid user production 212.128.3.152 port 60626 [preauth] +2026-10-06T12:04:38.873013329Z Timeout before authentication for connection from 212.19.8.30 to 172.19.0.29, pid = 308210 +2026-10-06T12:04:39.622456198Z Received disconnect from 172.19.0.1 port 53534:11: disconnected by user +2026-10-06T12:04:41.066933472Z Received disconnect from 172.19.0.1 port 53540:11: disconnected by user +2026-10-06T12:04:41.413322464Z Received disconnect from 172.19.0.1 port 53552:11: disconnected by user \ No newline at end of file diff --git a/ipabusedb-docker-ssh/main.go b/ipabusedb-docker-ssh/main.go new file mode 100644 index 0000000..ecef150 --- /dev/null +++ b/ipabusedb-docker-ssh/main.go @@ -0,0 +1,366 @@ +// ipabusedb-docker-ssh reads the logs of a dockerised sshd via +// "docker logs -t", extracts brute-force / invalid-user attempts, and +// emits a CSV suitable for the AbuseIPDB bulk-report endpoint. +// +// Environment: +// +// DOCKER_CONTAINER_NAME container whose logs are read (required) +// IPABUSEDB_API_KEY AbuseIPDB API key (required with --submit) +// +// Flags: +// +// --since 24h only consider log lines newer than now()-duration +// -o FILE write CSV to FILE instead of stdout +// --input FILE parse FILE (or "-" for stdin) instead of docker logs +// --submit POST the CSV to the AbuseIPDB bulk-report endpoint +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/csv" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/netip" + "os" + "os/exec" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +const ( + bulkReportURL = "https://api.abuseipdb.com/api/v2/bulk-report" + // AbuseIPDB categories: 18 = Brute-Force, 22 = SSH. + categories = "18,22" + excludedUser = "git" + // AbuseIPDB rejects bulk reports with more than this many rows. + bulkReportMaxRows = 10000 +) + +// patterns match sshd messages that evidence an unauthorised login +// attempt. Group 1 is the username, group 2 the source address. Order +// is priority: the earliest matching pattern supplies the report comment. +var patterns = []*regexp.Regexp{ + regexp.MustCompile(`Invalid user (\S+) from (\S+) port \d+`), + regexp.MustCompile(`User (\S+) from (\S+) not allowed because not listed in AllowUsers`), + regexp.MustCompile(`Failed password for invalid user (\S+) from (\S+) port \d+`), + regexp.MustCompile(`Connection closed by invalid user (\S+) (\S+) port \d+`), + regexp.MustCompile(`Disconnected from invalid user (\S+) (\S+) port \d+`), +} + +type event struct { + ip netip.Addr + user string + comment string + prio int // index into patterns; lower is more descriptive + when time.Time + count int +} + +// parser accumulates one event per source IP, keeping the most recent +// matching message and counting how many matched in total. +type parser struct { + cutoff time.Time + + mu sync.Mutex + events map[netip.Addr]*event + skipped int +} + +func newParser(cutoff time.Time) *parser { + return &parser{cutoff: cutoff, events: map[netip.Addr]*event{}} +} + +// handleLine parses one "docker logs -t" line: an RFC3339Nano timestamp, +// a space, then the message. +func (p *parser) handleLine(line string) { + line = strings.TrimRight(line, "\r\n") + ts, msg, ok := strings.Cut(line, " ") + if !ok { + return + } + when, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { + p.mu.Lock() + p.skipped++ + p.mu.Unlock() + return + } + if !p.cutoff.IsZero() && when.Before(p.cutoff) { + return + } + for prio, re := range patterns { + m := re.FindStringSubmatch(msg) + if m == nil { + continue + } + user, addr := m[1], m[2] + if user == excludedUser { + return + } + ip, err := netip.ParseAddr(addr) + if err != nil || !reportable(ip) { + return + } + p.record(ip, user, strings.TrimSpace(m[0]), prio, when) + return + } +} + +func reportable(ip netip.Addr) bool { + return ip.IsGlobalUnicast() && !ip.IsPrivate() +} + +// record merges an observation into the per-IP event: the report date is +// the latest observation, the comment comes from the most descriptive +// pattern (and, within the same pattern, the latest observation). +func (p *parser) record(ip netip.Addr, user, comment string, prio int, when time.Time) { + p.mu.Lock() + defer p.mu.Unlock() + ev, ok := p.events[ip] + if !ok { + p.events[ip] = &event{ip: ip, user: user, comment: comment, prio: prio, when: when, count: 1} + return + } + ev.count++ + newer := when.After(ev.when) + if newer { + ev.when = when + } + if prio < ev.prio || (prio == ev.prio && newer) { + ev.prio, ev.user, ev.comment = prio, user, comment + } +} + +func (p *parser) ingest(r io.Reader) error { + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + p.handleLine(sc.Text()) + } + return sc.Err() +} + +func (p *parser) sorted() []*event { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]*event, 0, len(p.events)) + for _, ev := range p.events { + out = append(out, ev) + } + sort.Slice(out, func(i, j int) bool { return out[i].when.Before(out[j].when) }) + return out +} + +func writeCSV(w io.Writer, events []*event) error { + cw := csv.NewWriter(w) + if err := cw.Write([]string{"IP", "Categories", "Comment", "ReportDate"}); err != nil { + return err + } + for _, ev := range events { + comment := ev.comment + if ev.count > 1 { + comment = fmt.Sprintf("%s (%d events)", ev.comment, ev.count) + } + row := []string{ + ev.ip.String(), + categories, + comment, + ev.when.UTC().Format(time.RFC3339), + } + if err := cw.Write(row); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} + +// readDockerLogs runs "docker logs -t [--since D] NAME" and feeds both +// stdout and stderr (sshd logs to stderr) through the parser. +func readDockerLogs(ctx context.Context, p *parser, container string, since time.Duration) error { + args := []string{"logs", "-t"} + if since > 0 { + args = append(args, "--since", since.String()) + } + args = append(args, container) + cmd := exec.CommandContext(ctx, "docker", args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("starting docker: %w", err) + } + + var wg sync.WaitGroup + errs := make(chan error, 2) + for _, r := range []io.Reader{stdout, stderr} { + wg.Add(1) + go func(r io.Reader) { + defer wg.Done() + if err := p.ingest(r); err != nil { + errs <- err + } + }(r) + } + wg.Wait() + close(errs) + if err := cmd.Wait(); err != nil { + return fmt.Errorf("docker logs %s: %w", container, err) + } + for err := range errs { + if err != nil { + return err + } + } + return nil +} + +func readInput(p *parser, path string) error { + if path == "-" { + return p.ingest(os.Stdin) + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + return p.ingest(f) +} + +func submit(ctx context.Context, apiKey string, csvData []byte) ([]byte, error) { + var body bytes.Buffer + mw := multipart.NewWriter(&body) + part, err := mw.CreateFormFile("csv", "reports.csv") + if err != nil { + return nil, err + } + if _, err := part.Write(csvData); err != nil { + return nil, err + } + if err := mw.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, bulkReportURL, &body) + if err != nil { + return nil, err + } + req.Header.Set("Key", apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", mw.FormDataContentType()) + + client := &http.Client{Timeout: 2 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + return respBody, fmt.Errorf("abuseipdb returned %s", resp.Status) + } + return respBody, nil +} + +func run() error { + since := flag.Duration("since", 0, "only report events newer than now()-duration (e.g. 24h)") + outPath := flag.String("o", "", "write CSV to this file instead of stdout") + inPath := flag.String("input", "", "parse this file (or - for stdin) instead of running docker logs") + doSubmit := flag.Bool("submit", false, "POST the CSV to the AbuseIPDB bulk-report endpoint") + flag.Parse() + + apiKey := os.Getenv("IPABUSEDB_API_KEY") + container := os.Getenv("DOCKER_CONTAINER_NAME") + if *doSubmit && apiKey == "" { + return errors.New("IPABUSEDB_API_KEY must be set in the environment to --submit") + } + if *inPath == "" && container == "" { + return errors.New("DOCKER_CONTAINER_NAME must be set in the environment") + } + + var cutoff time.Time + if *since > 0 { + cutoff = time.Now().Add(-*since) + } + p := newParser(cutoff) + + ctx := context.Background() + var err error + if *inPath != "" { + err = readInput(p, *inPath) + } else { + err = readDockerLogs(ctx, p, container, *since) + } + if err != nil { + return err + } + + events := p.sorted() + if len(events) > bulkReportMaxRows { + slog.Warn("truncating to AbuseIPDB bulk report limit", "limit", bulkReportMaxRows, "found", len(events)) + events = events[len(events)-bulkReportMaxRows:] + } + slog.Info("parsed logs", "abusers", len(events), "unparseable_lines", p.skipped) + + var buf bytes.Buffer + if err := writeCSV(&buf, events); err != nil { + return err + } + + out := io.Writer(os.Stdout) + if *outPath != "" { + f, err := os.Create(*outPath) + if err != nil { + return err + } + defer f.Close() + out = f + } + if _, err := out.Write(buf.Bytes()); err != nil { + return err + } + + if !*doSubmit { + return nil + } + if len(events) == 0 { + slog.Info("nothing to submit") + return nil + } + resp, err := submit(ctx, apiKey, buf.Bytes()) + if len(resp) > 0 { + fmt.Fprintln(os.Stderr, string(resp)) + } + if err != nil { + return err + } + slog.Info("submitted bulk report", "rows", len(events)) + return nil +} + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + if err := run(); err != nil { + slog.Error(err.Error()) + os.Exit(1) + } +} diff --git a/ipabusedb-docker-ssh/main_test.go b/ipabusedb-docker-ssh/main_test.go new file mode 100644 index 0000000..91c6cb4 --- /dev/null +++ b/ipabusedb-docker-ssh/main_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "net/netip" + "os" + "strings" + "testing" + "time" +) + +func TestParseExampleLines(t *testing.T) { + data, err := os.ReadFile("loglines_example.txt") + if err != nil { + t.Fatal(err) + } + p := newParser(time.Time{}) + if err := p.ingest(bytes.NewReader(data)); err != nil { + t.Fatal(err) + } + + want := map[string]int{ + "41.93.28.11": 2, + "45.191.4.252": 2, + "164.77.161.180": 2, + "212.128.3.152": 2, + } + if len(p.events) != len(want) { + t.Fatalf("got %d events, want %d", len(p.events), len(want)) + } + for ipStr, count := range want { + ev, ok := p.events[netip.MustParseAddr(ipStr)] + if !ok { + t.Errorf("missing event for %s", ipStr) + continue + } + if ev.count != count { + t.Errorf("%s: count %d, want %d", ipStr, ev.count, count) + } + } + + var out bytes.Buffer + if err := writeCSV(&out, p.sorted()); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if lines[0] != "IP,Categories,Comment,ReportDate" { + t.Errorf("bad header: %q", lines[0]) + } + if len(lines) != 5 { + t.Fatalf("got %d csv lines, want 5:\n%s", len(lines), out.String()) + } + if !strings.Contains(lines[1], "41.93.28.11,\"18,22\",") || !strings.HasSuffix(lines[1], "2026-10-06T12:04:24Z") { + t.Errorf("unexpected first row: %q", lines[1]) + } + // The descriptive "Invalid user ... from" line should win over the + // later "Connection closed by invalid user" line for the comment. + if !strings.Contains(lines[2], ",Invalid user production from 45.191.4.252 port 38924 (2 events),") { + t.Errorf("unexpected comment in row: %q", lines[2]) + } + // AllowUsers rejections are reported with their own message. + if !strings.Contains(lines[1], "User root from 41.93.28.11 not allowed because not listed in AllowUsers (2 events)") { + t.Errorf("unexpected comment in row: %q", lines[1]) + } +} + +func TestExclusions(t *testing.T) { + p := newParser(time.Time{}) + for _, line := range []string{ + "2026-10-06T12:04:24.687263829Z Invalid user git from 1.2.3.4 port 38924", + "2026-10-06T12:04:24.687263829Z User git from 1.2.3.5 not allowed because not listed in AllowUsers", + "2026-10-06T12:04:24.687263829Z Invalid user bob from 172.19.0.1 port 38924", + "2026-10-06T12:04:24.687263829Z Invalid user bob from 10.0.0.9 port 38924", + "2026-10-06T12:04:24.687263829Z Invalid user bob from 127.0.0.1 port 38924", + "2026-10-06T12:04:24.687263829Z Invalid user bob from fd00::1 port 38924", + "2026-10-06T12:04:24.687263829Z Invalid user bob from not-an-ip port 38924", + "2026-10-06T12:04:24.687263829Z Received disconnect from 8.8.8.8 port 53534:11: disconnected by user", + "garbage line without timestamp", + } { + p.handleLine(line) + } + if len(p.events) != 0 { + t.Errorf("expected no events, got %v", p.events) + } + if p.skipped != 1 { + t.Errorf("skipped = %d, want 1", p.skipped) + } +} + +func TestSinceCutoff(t *testing.T) { + cutoff := time.Date(2026, 10, 6, 12, 4, 29, 0, time.UTC) + p := newParser(cutoff) + p.handleLine("2026-10-06T12:04:24.687263829Z Invalid user production from 45.191.4.252 port 38924") + p.handleLine("2026-10-06T12:04:30.222573149Z Invalid user production from 212.128.3.152 port 60626") + if len(p.events) != 1 { + t.Fatalf("got %d events, want 1", len(p.events)) + } + if _, ok := p.events[netip.MustParseAddr("212.128.3.152")]; !ok { + t.Error("expected only the post-cutoff event to remain") + } +} + +func TestIPv6AndLatestWins(t *testing.T) { + p := newParser(time.Time{}) + p.handleLine("2026-10-06T12:04:24Z Invalid user a from 2001:db8::1 port 1") + p.handleLine("2026-10-06T12:04:26Z Invalid user b from 2001:db8::1 port 2") + p.handleLine("2026-10-06T12:04:25Z Invalid user c from 2001:db8::1 port 3") + ev := p.events[netip.MustParseAddr("2001:db8::1")] + if ev == nil || ev.count != 3 || ev.user != "b" { + t.Fatalf("unexpected event: %+v", ev) + } + var out bytes.Buffer + if err := writeCSV(&out, p.sorted()); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "(3 events)") { + t.Errorf("expected event count in comment:\n%s", out.String()) + } +}